Blockens Blockens Developers API v1
Blockens Trading API · v1

Trade stablecoins and pesos from your own systems

Ask for a firm price, confirm it, fund the trade and receive the payout. Pesos move by SPEI, stablecoins on-chain, and every step reaches you as a signed webhook.

Sandbox
https://sandbox-api.blockens.tech/v1

Simulated prices and settlement. No real money. Keys start with bk_test_.

Production
https://api.blockens.tech/v1

Real money. Keys start with bk_live_, issued after go-live.

What you can trade

PairPrice isMoves on
USDT/MXNMXN per 1 USDTUSDT: Tron, Ethereum, Solana, Polygon · MXN: SPEI
USDC/MXNMXN per 1 USDCUSDC: Ethereum, Solana, Polygon, Tron · MXN: SPEI
BTC/USDT, BTC/USDCUSDT or USDC per 1 BTCBTC: Bitcoin · on request
ETH/USDT, ETH/USDCUSDT or USDC per 1 ETHETH: Ethereum · on request

Every price is all-in: it already includes your agreed terms. The minimum is 25,000 USD per trade, measured on the stablecoin side. GET /v1/pairs lists the pairs enabled for your account.

Quickstart

Your first sandbox trade, end to end. You need the sandbox key Blockens sent you.

  1. Store your key as a secret.

    Keep it in an environment variable such as BLOCKENS_API_KEY, never in source code.

  2. Make a signed request.

    Use the helper below; it signs every call. Try GET /v1/pairs.

  3. Ask for a quote.

    POST /v1/quotes with a pair, a side and an amount. The price holds until expires_at.

  4. Confirm it.

    POST /v1/trades with the quote_id and one of your destination_ids. You get funding instructions and a pay_by time.

  5. Move the trade forward.

    In the sandbox, POST /v1/sandbox/trades/{id}/simulate plays the funding and the payout, and fires the webhooks you would get in production.

// Node 18+. No packages needed.
import crypto from 'node:crypto';

const BASE = 'https://sandbox-api.blockens.tech';
const KEY = process.env.BLOCKENS_API_KEY;

// Signs and sends one request. Pass the same idempotencyKey when you retry a POST.
export async function blockens(method, path, body, idempotencyKey = crypto.randomUUID()) {
  const raw = body === undefined ? '' : JSON.stringify(body);
  const ts = String(Math.floor(Date.now() / 1000));
  const signature = crypto.createHmac('sha256', KEY)
    .update(`${ts}.${method}.${path}.${raw}`).digest('hex');
  const headers = {
    Authorization: `Bearer ${KEY}`,
    'X-Blockens-Timestamp': ts,
    'X-Blockens-Signature': signature,
  };
  if (method === 'POST') {
    headers['Content-Type'] = 'application/json';
    headers['Idempotency-Key'] = idempotencyKey;
  }
  const res = await fetch(BASE + path, { method, headers, body: raw || undefined });
  const data = await res.json();
  if (!res.ok) throw Object.assign(new Error(data.error.message), data.error);
  return data;
}

const quote = await blockens('POST', '/v1/quotes', { pair: 'USDT/MXN', side: 'buy', send_amount: '1000000.00' });
const trade = await blockens('POST', '/v1/trades', { quote_id: quote.quote_id, destination_id: 'dst_test_tron', client_reference: 'ORDER-1001' });
console.log(trade.id, trade.status, trade.funding);

Authentication

Every request carries your API key and a signature. Blockens stores only a hash of your key, so nobody at Blockens can read it; if you lose it, ask for a new one.

Headers on every request
Authorization: Bearer <your API key>
X-Blockens-Timestamp: <Unix time in seconds>
X-Blockens-Signature: <hex HMAC-SHA256 of the signing string, keyed with your API key>

The signing string joins four parts with a dot:

1729450865.POST./v1/quotes.{"pair":"USDT/MXN","side":"buy","send_amount":"1000000.00"}
Timestamp
The same value as X-Blockens-Timestamp. Within 300 seconds of our clock.
Method
Upper case: GET or POST.
Path
Starts at /v1 and includes the query string, exactly as sent.
Body
The exact bytes you send. Empty for GET (the string still ends with a dot).
  • Sign the bytes you send. Serialize the JSON once and send that same string; re-serializing after signing changes the bytes.
  • IP allowlist. Production keys work only from the server IPs you registered with Blockens. Calls from anywhere else get 403 ip_not_allowed.
  • Key rotation. When Blockens issues you a new key, the old one keeps working for 24 hours so you can switch without downtime. A key that may have leaked is stopped at once.

Idempotency

Every POST needs an Idempotency-Key header: 8 to 80 letters, digits, - or _. A UUID is ideal. It makes retries safe: a confirm retried after a timeout can never create two trades.

You send the same key…You get
with the same request, within 24 hoursThe first response again, with the header Idempotent-Replayed: true. This includes refusals: a request refused once is refused the same way.
with a different body or path409 idempotency_conflict. Use a new key for a new request.
while the first request is still running409 request_in_progress. Wait a moment and retry with the same key.

Generate the key once per intended action, store it with your order, and reuse it for every retry of that action.

Conventions and limits

  • JSON over HTTPS. Send Content-Type: application/json on POST. Bodies up to 64 KB.
  • Amounts are decimal strings, such as "1000000.00", never floats. Decimals: MXN 2, USDT and USDC 6, BTC and ETH 8.
  • Times are ISO 8601 in UTC, such as 2026-10-20T16:21:05.000Z.
  • IDs: quotes q_…, trades T-000123 (TS-000123 in the sandbox), webhook events evt_…, requests req_….
  • Every response carries X-Request-Id. Quote it when you contact us.

Rate limits

WhatLimit, per key
POST /v1/quotes60 per minute
POST /v1/trades30 per minute
All requests300 per minute

Over a limit you get 429 rate_limited with Retry-After: 60. Back off; do not retry in a tight loop. Need more? Tell us your peak.

GET/v1/pairs

The pairs enabled for your account, with their minimum, networks and decimals.

Response 200
{
  "data": [
    {
      "pair": "USDT/MXN", "base": "USDT", "quote": "MXN", "min_usd": "25000",
      "networks": { "USDT": ["tron", "ethereum", "solana", "polygon"], "MXN": null },
      "decimals": { "USDT": 6, "MXN": 2 }
    }
  ]
}

GET/v1/destinations

Where Blockens can pay you: your approved wallets and CLABEs. New destinations are added with Blockens, never through the API; each wallet is approved and receives a small test transfer first.

Response 200
{
  "data": [
    { "id": "6f1c2a9e-…", "kind": "wallet", "network": "tron", "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "bank": null, "label": "Treasury" },
    { "id": "0b7d41c3-…", "kind": "clabe", "network": null, "address": "127180001234567894", "bank": "Banco Azteca", "label": "Operations" }
  ]
}

In the sandbox you get test destinations: dst_test_tron, dst_test_ethereum, dst_test_solana, dst_test_polygon, dst_test_bitcoin and dst_test_clabe.

POST/v1/beneficiaries

To pay pesos to your customers' recipients in Mexico, register each recipient once. Blockens reviews it before the first payout and tells you by webhook (beneficiary.approved, beneficiary.rejected or beneficiary.file_requested). As an institution you keep your own KYC files; Blockens may ask for a customer's file at any time.

FieldRequiredMeaning
kindyes"person" or "company".
nameyesFull legal name, as on the bank account.
tax_idyesRFC (companies, people) or CURP (people).
clabeyes18-digit CLABE in the beneficiary's own name.
emailnoFor the payment notice.
client_referencenoYour own ID for this beneficiary.
Response 201
{
  "id": "ben_4Fq9xLm2Tz8Wk3Pa",
  "environment": "live",
  "kind": "person",
  "clabe_last4": "0004",
  "bank": "STP",
  "client_reference": "CUST-881",
  "status": "pending_review",
  "review_note": null,
  "created_at": "2026-10-21T18:02:11.000Z"
}
  • GET /v1/beneficiaries lists yours; GET /v1/beneficiaries/{id} shows one. Names, tax IDs and full CLABEs are never returned: Blockens keeps them sealed.
  • A CLABE can be registered once (beneficiary_exists returns the existing id). In the sandbox, beneficiaries are approved at once.

POST/v1/quotes

A firm, all-in price for one trade. Give what you send or what you want to receive; Blockens returns the other side.

FieldRequiredMeaning
pairyesOne of your pairs, such as "USDT/MXN".
sideyes"buy": you buy the first asset (pay with the second). "sell": you sell the first asset (receive the second).
send_amountone of the twoThe amount you send, as a decimal string.
receive_amountone of the twoThe amount you want to receive. Blockens works out what you send, rounded up to the asset's decimals.
send_networknoWhen you send crypto: the network you send it on (such as "tron"). Defaults to the asset's first network.
markupnoYour own charge to your customer, on top of Blockens' price: {"type":"bps","value":"25"} (up to 1000) or {"type":"fixed","value":"150.00"} in the asset received. It is taken from what is received, shown as markup in the quote and the trade, and paid to you by Blockens.
Request
{ "pair": "USDT/MXN", "side": "buy", "send_amount": "1000000.00" }
Response 201
{
  "quote_id": "q_7Hk2mP9xQe4T",
  "environment": "sandbox",
  "pair": "USDT/MXN",
  "side": "buy",
  "price": "18.474102",
  "send":    { "asset": "MXN",  "amount": "1000000.00", "network": null },
  "fee":     { "asset": "MXN",  "amount": "0.00", "bps": "0" },
  "receive": { "asset": "USDT", "amount": "54129.829964" },
  "expires_at": "2026-10-20T16:21:35.000Z"
}
  • price is the second asset per 1 of the first (MXN per USDT here). fee is your agreed service fee, taken from what you send (zero if none).
  • A quote binds nothing until you confirm it. Always read expires_at; how long a price holds depends on the market (30 seconds in the sandbox).
  • Errors you should handle: below_minimum, pair_not_enabled, invalid (with fields), over_limit (your limits per trade, per day or unfunded), liquidity_unavailable. Live quotes last up to 30 seconds: confirm promptly. If the market moved meaningfully since your quote, confirming answers 409 price_changed: ask for a new quote.

POST/v1/trades

Confirms a quote. From this moment the price is locked and the trade must be funded by pay_by.

FieldRequiredMeaning
quote_idyesFrom POST /v1/quotes, before it expires.
destination_idone of the twoWhere you are paid: a wallet on a network the received asset uses, or a CLABE for pesos.
beneficiary_idone of the twoPesos paid to an approved beneficiary (by SPEI) on behalf of your customer.
on_behalf_ofwith beneficiary_idYour customer who sends this payment: {"name":"…","reference":"…"}.
client_referencenoYour own ID, up to 64 characters (letters, digits, . _ : / -). Echoed in every response and webhook.
Response 201
{
  "id": "TS-000123",
  "environment": "sandbox",
  "status": "confirmed",
  "client_reference": "ORDER-1001",
  "pair": "USDT/MXN",
  "side": "buy",
  "price": "18.474102",
  "send":    { "asset": "MXN",  "amount": "1000000.00", "network": null },
  "fee":     { "asset": "MXN",  "amount": "0.00", "bps": "0" },
  "receive": { "asset": "USDT", "amount": "54129.829964", "network": "tron", "destination_id": "dst_test_tron" },
  "funding": { "method": "spei", "bank": "Sandbox Bank", "clabe": "000180000000000001", "reference": "0000123" },
  "pay_by": "2026-10-20T20:21:05.000Z",
  "created_at": "2026-10-20T16:21:05.000Z",
  "steps": [ { "name": "confirmed", "at": "2026-10-20T16:21:05.000Z", "proof": null } ],
  "completed_at": null,
  "penalty": null
}
  • When you send crypto, funding is { "method": "onchain", "asset": "USDT", "network": "tron", "address": "T…" }.
  • Pay pesos only from your registered CLABE, with the 7-digit reference in the SPEI concept. Send crypto only from your whitelisted wallets. Funds from anywhere else are returned, not applied.
  • Errors you should handle: quote_expired and quote_used (ask for a new quote), over_limit (fund open trades first), destination_not_active.

GET/v1/trades/{id}

One trade, as above, with every step so far. Each step has name, at and proof (the SPEI tracking key or transaction hash, when there is one). A completed trade also has proof and completed_at; a trade that was not completed has penalty: { "pct": "10", "asset": "MXN", "amount": "100000.00" }.

GET/v1/trades

Your trades, newest first, 50 per page.

QueryMeaning
statusOnly trades in this status.
from, toCreated at or after from, before to (ISO 8601).
cursorThe next_cursor of the previous page.
Response 200
{ "data": [ { "id": "TS-000124", "status": "completed", … } ], "next_cursor": null }

Remember to sign the path with its query string: /v1/trades?status=completed.

POST/v1/sandbox/trades/{id}/simulate

Sandbox only. Moves a test trade forward as if the money had moved, and sends the matching webhook.

Request
{ "event": "funds_received" }
eventAllowed when the trade isResult
funds_receivedconfirmedYour funds arrived.
completedfunds_receivedSettled and paid out, with a test proof.
not_completedconfirmedNot funded in time; the 10% penalty is shown.
cancelledconfirmedClosed before any money moved.

Anything else returns 409 invalid_state.

Trade lifecycle

A confirmed trade only moves forward; it is never edited. Blockens pays out only after your funds have arrived.

confirmed→funds_received→settling→completed
executing→confirmedornot_filled
confirmed→not_completedorcancelled
StatusMeaningWebhook
executingLive only: your confirmation is being executed at the quoted price. Usually lasts milliseconds; if it takes longer, POST /v1/trades answers 202 with this status and the webhook follows.none
not_filledLive only: the quoted price could not be executed. Nothing is owed; ask for a new quote. POST /v1/trades answers 409 not_filled with the trade_id.trade.not_filled
confirmedPrice locked; waiting for your funds until pay_by. The response carries funding: where to send them.trade.confirmed
funds_receivedYour SPEI or on-chain transfer arrived.trade.funds_received
settlingBlockens is settling the trade (a step in steps; live trades keep the status funds_received until completed).trade.settling (live)
completedPaid out to your destination, with its proof.trade.completed
not_completedNo funds by pay_by; the agreed 10% penalty applies.trade.not_completed
cancelledClosed before any money moved.trade.cancelled

Your unfunded trades count against an exposure limit agreed with you. A confirm that would pass it returns 422 over_limit.

Webhooks

Blockens sends each trade step to the HTTPS address you registered, so you never need to poll.

POST to your webhook address
X-Blockens-Event: trade.completed
X-Blockens-Timestamp: 1729450942
X-Blockens-Signature: 4f2a…c91e

{
  "id": "evt_3Qm8yK2pLsW7xa",
  "type": "trade.completed",
  "created_at": "2026-10-20T17:05:42.000Z",
  "data": { "id": "TS-000123", "status": "completed", "client_reference": "ORDER-1001", "proof": "7f3c…e91a", … }
}
  • Events: trade.confirmed, trade.not_filled, trade.funds_received, trade.settling, trade.completed, trade.not_completed, trade.cancelled, beneficiary.created, beneficiary.approved, beneficiary.rejected, beneficiary.file_requested (with a note saying what Blockens needs), and ping (a test Blockens can send at any time). For trades, data is the full trade.
  • Verify every webhook. The signature is hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your webhook secret (whsec_…, separate from your API key). Reject anything older than 300 seconds.
  • Answer 2xx within 10 seconds. Otherwise Blockens retries after 1, 2, 5, 10 and 30 minutes, then 1, 2, 4 and 6 hours: 10 attempts in all. Redirects are not followed.
  • At least once. The same event can arrive twice: ignore repeats by id. Order is not guaranteed: trust status and GET /v1/trades/{id}.
Node.js: verify a webhook
import crypto from 'node:crypto';

// rawBody: the request body exactly as received (a Buffer or string), before any JSON parsing.
export function verifyBlockensWebhook(headers, rawBody, secret = process.env.BLOCKENS_WEBHOOK_SECRET) {
  const ts = headers['x-blockens-timestamp'];
  const given = String(headers['x-blockens-signature'] || '');
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex');
  return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

Errors

Every error has the same shape, with a stable code your software can act on, a message for people, and a request_id to quote to us. Validation errors add fields, one message per field.

Error body
{ "error": { "code": "quote_expired", "message": "The quote expired at 16:21:35Z. Ask for a new one.", "request_id": "req_9Xw2kQ7mLp" } }
HTTPcodeWhat to do
400bad_requestFix the request: the body is not a JSON object, or a POST has no valid Idempotency-Key. (A body over 64 KB gets the same code with HTTP 413.)
401unauthorizedMissing, unknown or revoked key, or a production key before go-live.
401bad_signatureRebuild the signing string: timestamp, method, path with query, body, joined by dots.
401timestamp_out_of_windowYour server clock is more than 300 seconds off. Sync it (NTP).
403ip_not_allowedCall from a registered IP, or ask Blockens to register this one.
403pair_not_enabledAsk Blockens to enable the pair.
404not_foundUnknown trade, quote or path.
405method_not_allowedUse the method shown for the endpoint.
409quote_expired, quote_usedAsk for a new quote.
409idempotency_conflict, request_in_progressSee Idempotency.
409invalid_stateSandbox: simulate the steps in order.
422invalidRead fields and fix each one.
422below_minimumTrade at least 25,000 USD.
422over_limitFund your open trades before confirming new ones.
422destination_not_activeUse an id from GET /v1/destinations.
409beneficiary_existsThat CLABE is already registered: use the beneficiary_id returned.
422beneficiary_not_approvedWait for beneficiary.approved before paying that beneficiary.
429rate_limitedWait Retry-After seconds.
503liquidity_unavailablePricing is paused (market hours or volatility). Retry later.
500internal_errorRetry with the same Idempotency-Key; contact us with the request_id if it persists.

Sandbox

The sandbox behaves like production with simulated money. Build your whole integration there, unhappy paths included.

  • Prices move every 5 seconds. Quotes hold 30 seconds.
  • The 25,000 USD minimum and an exposure limit on unfunded trades apply, so you can test both errors.
  • Test destinations (dst_test_…) and test funding instructions; nothing is ever paid.
  • Trades are numbered TS-… and move forward only with simulate.

The checks Blockens sees pass

Blockens sees these tick off as your system does them. All of them are needed before production.

  • A signed request is accepted.
  • A wrongly signed request is refused with 401 bad_signature.
  • A quote is confirmed for a buy, and another for a sell.
  • A confirm retried with the same Idempotency-Key returns the same trade.
  • An expired quote is refused with 409 quote_expired, and your system asks for a new one.
  • The exposure limit is reached (422 over_limit) and handled without retry storms.
  • A webhook is delivered to your address and answered with 2xx.
  • A trade is followed to completed, and another to not_completed.

Going live

  1. Onboarding is complete.

    KYB approved, agreement signed, and your terms (fee, limit, payment window) approved by Blockens.

  2. Your payout destinations are approved.

    Each wallet is approved and receives a small test transfer; each CLABE is verified.

  3. Your production details are registered.

    The public IPs of your production servers, your production webhook address and an on-call contact.

  4. All sandbox checks have passed.
  5. You receive your production key.

    Through a one-time secure link, after two Blockens partners approve. We watch your first live trade with you.

Support

Write to your Blockens contact with the request_id from the response or the X-Request-Id header, the time, and what you expected. With the request id we can see exactly what reached us.