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.
https://sandbox-api.blockens.tech/v1Simulated prices and settlement. No real money. Keys start with bk_test_.
https://api.blockens.tech/v1Real money. Keys start with bk_live_, issued after go-live.
What you can trade
| Pair | Price is | Moves on |
|---|---|---|
USDT/MXN | MXN per 1 USDT | USDT: Tron, Ethereum, Solana, Polygon · MXN: SPEI |
USDC/MXN | MXN per 1 USDC | USDC: Ethereum, Solana, Polygon, Tron · MXN: SPEI |
BTC/USDT, BTC/USDC | USDT or USDC per 1 BTC | BTC: Bitcoin · on request |
ETH/USDT, ETH/USDC | USDT or USDC per 1 ETH | ETH: 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.
- Store your key as a secret.
Keep it in an environment variable such as
BLOCKENS_API_KEY, never in source code. - Make a signed request.
Use the helper below; it signs every call. Try
GET /v1/pairs. - Ask for a quote.
POST /v1/quoteswith a pair, a side and an amount. The price holds untilexpires_at. - Confirm it.
POST /v1/tradeswith thequote_idand one of yourdestination_ids. You get funding instructions and apay_bytime. - Move the trade forward.
In the sandbox,
POST /v1/sandbox/trades/{id}/simulateplays 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);
# Python 3.8+. Standard library only. import hashlib, hmac, json, os, time, uuid, urllib.request, urllib.error BASE = "https://sandbox-api.blockens.tech" KEY = os.environ["BLOCKENS_API_KEY"] def blockens(method, path, body=None, idempotency_key=None): """Signs and sends one request. Pass the same idempotency_key when you retry a POST.""" raw = "" if body is None else json.dumps(body, separators=(",", ":")) ts = str(int(time.time())) signature = hmac.new(KEY.encode(), f"{ts}.{method}.{path}.{raw}".encode(), hashlib.sha256).hexdigest() headers = {"Authorization": f"Bearer {KEY}", "X-Blockens-Timestamp": ts, "X-Blockens-Signature": signature} if method == "POST": headers["Content-Type"] = "application/json" headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4()) req = urllib.request.Request(BASE + path, data=raw.encode() if raw else None, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as res: return json.load(res) except urllib.error.HTTPError as e: error = json.load(e)["error"] raise RuntimeError(f"{e.code} {error['code']}: {error['message']} ({error['request_id']})") quote = blockens("POST", "/v1/quotes", {"pair": "USDT/MXN", "side": "buy", "send_amount": "1000000.00"}) trade = blockens("POST", "/v1/trades", {"quote_id": quote["quote_id"], "destination_id": "dst_test_tron", "client_reference": "ORDER-1001"}) print(trade["id"], trade["status"], trade["funding"])
# bash with curl and openssl. Good for a first test. BASE="https://sandbox-api.blockens.tech" BODY='{"pair":"USDT/MXN","side":"buy","send_amount":"1000000.00"}' TS=$(date +%s) SIG=$(printf '%s' "$TS.POST./v1/quotes.$BODY" | openssl dgst -sha256 -hmac "$BLOCKENS_API_KEY" -hex | sed 's/^.* //') curl -sS -X POST "$BASE/v1/quotes" \ -H "Authorization: Bearer $BLOCKENS_API_KEY" \ -H "X-Blockens-Timestamp: $TS" \ -H "X-Blockens-Signature: $SIG" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d "$BODY"
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.
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:
- Timestamp
- The same value as
X-Blockens-Timestamp. Within 300 seconds of our clock. - Method
- Upper case:
GETorPOST. - Path
- Starts at
/v1and 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 hours | The 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 path | 409 idempotency_conflict. Use a new key for a new request. |
| while the first request is still running | 409 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/jsonon 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_…, tradesT-000123(TS-000123in the sandbox), webhook eventsevt_…, requestsreq_…. - Every response carries
X-Request-Id. Quote it when you contact us.
Rate limits
| What | Limit, per key |
|---|---|
POST /v1/quotes | 60 per minute |
POST /v1/trades | 30 per minute |
| All requests | 300 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.
{
"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.
{
"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.
| Field | Required | Meaning |
|---|---|---|
kind | yes | "person" or "company". |
name | yes | Full legal name, as on the bank account. |
tax_id | yes | RFC (companies, people) or CURP (people). |
clabe | yes | 18-digit CLABE in the beneficiary's own name. |
email | no | For the payment notice. |
client_reference | no | Your own ID for this beneficiary. |
{
"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/beneficiarieslists 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_existsreturns 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.
| Field | Required | Meaning |
|---|---|---|
pair | yes | One of your pairs, such as "USDT/MXN". |
side | yes | "buy": you buy the first asset (pay with the second). "sell": you sell the first asset (receive the second). |
send_amount | one of the two | The amount you send, as a decimal string. |
receive_amount | one of the two | The amount you want to receive. Blockens works out what you send, rounded up to the asset's decimals. |
send_network | no | When you send crypto: the network you send it on (such as "tron"). Defaults to the asset's first network. |
markup | no | Your 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. |
{ "pair": "USDT/MXN", "side": "buy", "send_amount": "1000000.00" }{
"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"
}priceis the second asset per 1 of the first (MXN per USDT here).feeis 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(withfields),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 answers409 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.
| Field | Required | Meaning |
|---|---|---|
quote_id | yes | From POST /v1/quotes, before it expires. |
destination_id | one of the two | Where you are paid: a wallet on a network the received asset uses, or a CLABE for pesos. |
beneficiary_id | one of the two | Pesos paid to an approved beneficiary (by SPEI) on behalf of your customer. |
on_behalf_of | with beneficiary_id | Your customer who sends this payment: {"name":"…","reference":"…"}. |
client_reference | no | Your own ID, up to 64 characters (letters, digits, . _ : / -). Echoed in every response and webhook. |
{
"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,
fundingis{ "method": "onchain", "asset": "USDT", "network": "tron", "address": "T…" }. - Pay pesos only from your registered CLABE, with the 7-digit
referencein the SPEI concept. Send crypto only from your whitelisted wallets. Funds from anywhere else are returned, not applied. - Errors you should handle:
quote_expiredandquote_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.
| Query | Meaning |
|---|---|
status | Only trades in this status. |
from, to | Created at or after from, before to (ISO 8601). |
cursor | The next_cursor of the previous page. |
{ "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.
{ "event": "funds_received" }| event | Allowed when the trade is | Result |
|---|---|---|
funds_received | confirmed | Your funds arrived. |
completed | funds_received | Settled and paid out, with a test proof. |
not_completed | confirmed | Not funded in time; the 10% penalty is shown. |
cancelled | confirmed | Closed 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.
| Status | Meaning | Webhook |
|---|---|---|
executing | Live 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_filled | Live 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 |
confirmed | Price locked; waiting for your funds until pay_by. The response carries funding: where to send them. | trade.confirmed |
funds_received | Your SPEI or on-chain transfer arrived. | trade.funds_received |
settling | Blockens is settling the trade (a step in steps; live trades keep the status funds_received until completed). | trade.settling (live) |
completed | Paid out to your destination, with its proof. | trade.completed |
not_completed | No funds by pay_by; the agreed 10% penalty applies. | trade.not_completed |
cancelled | Closed 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.
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 anotesaying what Blockens needs), andping(a test Blockens can send at any time). For trades,datais 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: truststatusandGET /v1/trades/{id}.
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": { "code": "quote_expired", "message": "The quote expired at 16:21:35Z. Ask for a new one.", "request_id": "req_9Xw2kQ7mLp" } }| HTTP | code | What to do |
|---|---|---|
| 400 | bad_request | Fix 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.) |
| 401 | unauthorized | Missing, unknown or revoked key, or a production key before go-live. |
| 401 | bad_signature | Rebuild the signing string: timestamp, method, path with query, body, joined by dots. |
| 401 | timestamp_out_of_window | Your server clock is more than 300 seconds off. Sync it (NTP). |
| 403 | ip_not_allowed | Call from a registered IP, or ask Blockens to register this one. |
| 403 | pair_not_enabled | Ask Blockens to enable the pair. |
| 404 | not_found | Unknown trade, quote or path. |
| 405 | method_not_allowed | Use the method shown for the endpoint. |
| 409 | quote_expired, quote_used | Ask for a new quote. |
| 409 | idempotency_conflict, request_in_progress | See Idempotency. |
| 409 | invalid_state | Sandbox: simulate the steps in order. |
| 422 | invalid | Read fields and fix each one. |
| 422 | below_minimum | Trade at least 25,000 USD. |
| 422 | over_limit | Fund your open trades before confirming new ones. |
| 422 | destination_not_active | Use an id from GET /v1/destinations. |
| 409 | beneficiary_exists | That CLABE is already registered: use the beneficiary_id returned. |
| 422 | beneficiary_not_approved | Wait for beneficiary.approved before paying that beneficiary. |
| 429 | rate_limited | Wait Retry-After seconds. |
| 503 | liquidity_unavailable | Pricing is paused (market hours or volatility). Retry later. |
| 500 | internal_error | Retry 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 asell. - A confirm retried with the same
Idempotency-Keyreturns 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 tonot_completed.
Going live
- Onboarding is complete.
KYB approved, agreement signed, and your terms (fee, limit, payment window) approved by Blockens.
- Your payout destinations are approved.
Each wallet is approved and receives a small test transfer; each CLABE is verified.
- Your production details are registered.
The public IPs of your production servers, your production webhook address and an on-call contact.
- All sandbox checks have passed.
- 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.