Skip to content

Partner integration guide

SmartRemit provides the orchestration layer for cross-border remittance: the WhatsApp conversation, quoting, compliance screening, customer KYC flows, and a hosted pay page — under your brand. You remain the licensed money transmitter: funds never touch SmartRemit. We send your rail a signed settlement instruction; your rail reports lifecycle status back via a signed webhook.

Where we are today

SmartRemit is a working demonstration of production-grade remittance infrastructure. The AI conversation, live FX quoting, signed instruction-and-callback loop, durable processing, dashboards and WhatsApp notifications are real. Actual fund movement, the production identity-verification vendor, a commercial sanctions feed, and a live payout rail are simulated today — a reference "simulator" rail runs the exact signed loop a production rail would. We'll only describe those as live once they are.

Sanctions screening runs on every transfer and is structurally impossible to switch off, in every mode. (In today's demonstration it runs against a built-in reference rule set, not yet a live commercial AML feed.)

1 · The Partner API

Base URL https://smartremit.ai/api/partner/v1. Authenticate every request with your API key (issued in the dashboard, shown once):

Authorization: Bearer <your-api-key>

Errors are JSON: { "error": "…" }. 503 means live FX is temporarily unavailable (or another transfer for the same sender is in progress) and nothing was minted — retry later (a POST /transactions retry may reuse the same Idempotency-Key; see Idempotency).

Keys, environments and scopes
Every key is either live or test, and each route needs one scope.
  • A live key starts sr_live_; a test (sandbox) key starts sr_test_. The mode comes from the key itself: the whole key, prefix included, is what we verify, so editing the prefix only makes the key invalid (401).
  • A test key only ever creates and sees sandbox transactions. They settle on the mock rail, never message a customer or recipient, and can never be paid on the hosted pay page. Sanctions screening still runs on every sandbox transaction. A live key never sees sandbox transactions, and a test key never sees live ones (404).
  • A key is a long-lived bearer secret: it does not expire, and it is shown once, when it is issued on your partner page in the dashboard. Keep it server-side. To rotate, issue a new key, move your traffic to it, then revoke the old one there. A revoked or unknown key gets 401 Invalid or revoked API key.
RouteScopeLive keyTest key
GET /corridorscorridors:readYesYes (sandbox only)
POST /quotequoteYesYes (sandbox only)
POST /beneficiaries/validatebeneficiaries:validateYesYes (sandbox only)
POST /beneficiariesbeneficiaries:writeYesNo
GET /transactions · GET /transactions/:idtransactions:readYesYes (sandbox only)
POST /transactions · POST /transactions/:id/confirmtransactions:writeYesYes (sandbox only)
GET /ratesrates:readYesNo
PUT /ratesrates:writeYesNo
GET /settlementssettlements:readYesNo

A key is issued with every scope its mode allows, as in the table. A request outside the key's scopes gets 403 { "error": "This key cannot perform this action." }. A key whose partner is not active gets 403 { "error": "Partner not active." }.

Rate limits
  • 120 requests per minute per partner, shared by all of your keys, live and test together. Each key is also capped at 120 per minute on its own.
  • The window is a fixed calendar minute (UTC), not a rolling 60 seconds: the count resets at the start of each minute.
  • Every request with a valid key counts, including ones answered 403 or 429. A request refused 401 (missing, unknown or revoked key) is not counted.
  • Over the limit you get 429 with body { "error": "Rate limit exceeded." } and the header Retry-After: 60 (always 60 seconds, whatever is left of the minute). Back off before retrying, and add random jitter so your workers don't all retry at the same moment.
GET/corridorsYour enabled send corridors + brand
POST/quotePrice a transfer (amount_source, source_currency)
POST/beneficiaries/validateValidate payout fields for a country
POST/beneficiariesStore a beneficiary (payout details encrypted at rest)
POST/transactionsMint a transfer — Idempotency-Key header REQUIRED
GET/transactionsList your transfers (keyset: ?limit=&cursor=)
GET/transactions/:idFetch one transfer (404 outside your scope)
GET/settlementsSettlements statement for reconciliation (?from=&to=&limit=&cursor=&format=json|csv)
POST/transactions/:id/confirmConfirm funds captured → settlement begins (a flagged transfer is held in_review for compliance release; a blocked one is 422)
PUT/ratesPush one corridor's wholesale conversion rate
GET/ratesYour current rate sheet (freshness + margin)
# Mint a transfer (idempotent — safe to retry with the same key)
curl -X POST $BASE/transactions \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: 5f0c8a3e-2b7d-4e61-9a4f-0d3b8c1e7a52" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_source": 200,
    "source_currency": "USD",
    "sender":      { "phone": "15551230000", "name": "Maria Lopez", "kyc_status": "verified" },
    "beneficiary": { "name": "Anita Sharma", "phone": "919876543210",
                     "payout_method": "bank", "payout_destination": "123456789012|HDFC0001234" }
  }'

Compliance screening (sanctions) runs on every mint regardless of KYC mode — a watchlist hit returns 422 and the attempt is recorded as blocked. (In today's demonstration it runs against a built-in reference rule set, not yet a live commercial AML feed.) A later request with the same Idempotency-Key returns that blocked transaction with 200 — see Idempotency. A payout_destination that is a masked display value (for example ****1234 or account on file) is refused with 422 before the Idempotency-Key is bound. Idempotency-Key values beginning draft:, b2binvoice:, sched: or test: are reserved and refused with 400. A payer can never change the beneficiary account of a transaction created through this API: every transaction is bound to its Idempotency-Key before it is created, and that binding locks the account. A transaction still awaiting_payment and unpaid 7 days after it was created expires: its status becomes cancelled and it can no longer be paid.

Names — beneficiary.name, sender.name and the name of a stored beneficiary — must be 1–80 characters with no brackets ([ ] { } < >) and no control or line-break characters. payout_method must be one of bank, upi or usdc (default bank), and an inline payout_destination is at most 64 printable characters. destination_country is optional and defaults to IN; when present it must be one of US, CA, GB, AE, SG, AU, NZ, IN, HK, MX — any other value is refused with 400 (it is never coerced to India). Each is refused with 400 before the Idempotency-Key is bound, so a corrected retry with the same key succeeds. Transactions created through this API are never added to the customer's saved recipients in chat.sender.name is optional today but strongly recommended: a transaction created without it is held for manual review (it is created with compliance_status flagged, and confirming it returns in_review until compliance staff release it). sender.name will become required in a future version.

Idempotency
How the Idempotency-Key header on POST /transactions behaves.
  • The header is required on POST /transactions only (missing: 400). No other route is idempotent: in particular, retrying POST /beneficiaries stores a second beneficiary.
  • Use a fresh random UUID per transaction and put no personal data in it. We recommend at most 255 characters. Keys are kept indefinitely, per partner and per environment: the same key sent with a test key and with a live key makes two separate transactions.
  • Once a transaction exists under a key, every request with that key and a valid body returns that same transaction: 200 with the same id and its current state (not a copy of the first response). A concurrent duplicate can also get 201 for the same id, or a retryable 503.
  • The body is validated but not compared. A different body that passes validation returns the original transaction and is not applied; one that fails validation is refused as usual (400/404/422). Never reuse a key for a different transfer.
  • This includes a transaction blocked by sanctions screening: the first request gets 422, a repeat with the same key gets 200 with the blocked transaction. Always read status and compliance_status in the body, not only the HTTP code.
  • If a request failed with any error other than 409 and no transaction was created, a retry with the same key is validated again from scratch using the body you send then, and creates the transaction if it now succeeds.
  • 409 Idempotency-Key conflict. Retry with a new key. is rare and means exactly that: the key can no longer be used, so retry with a new one.
  • Reserved prefixes, refused with 400: draft:, b2binvoice:, sched: and test:.
GET /settlements — settlements statement
Reconcile your book against ours: every transfer of yours that was paid and sent for settlement in a time window.

SmartRemit is non-custodial, so this is the instruction ledger (what was paid and instructed to a rail), not a record of funds held. In today's demonstration the payout rail is the reference simulator, so provider_ref values and delivery are simulated.

  • The window is half-open [from, to) on paid_at, in UTC. from/to take a date (2026-09-01, midnight UTC) or a datetime (no zone means UTC). Default: yesterday. At most 31 days; a longer or reversed window is 400.
  • Listed: paid and delivered transfers, and cancelled ones only if a rail was instructed (see refund_status). A transfer held for compliance review (in_review) is never listed; once released it appears on its release day.
  • Oldest first. limit 1–500 (default 100). Pass next_cursor back as cursor to get the next page; treat it as opaque. null means the last page.
  • totals cover this page only (every listed row, cancelled included), per currency, in integer minor units (cents: 20000 = 200.00).
  • format=csv returns text/csv as an attachment, and the next cursor in the X-Next-Cursor header. A text cell starting with = + - @, a tab or a carriage return is prefixed with ' so a spreadsheet never runs it as a formula.
  • Results are always scoped to your API key's partner; any partner_id parameter is ignored.
curl "$BASE/settlements?from=2026-09-01&to=2026-09-08&limit=100" \
  -H "Authorization: Bearer $KEY"

{
  "settlements": [
    { "reference": "Qm9…", "status": "delivered", "compliance_status": "cleared",
      "refund_status": "none", "amount_source": 200, "source_currency": "USD",
      "fee_source": 1.99, "total_charge_source": 201.99, "fx_rate": 85.2,
      "amount_destination": 17040, "destination_currency": "INR",
      "destination_country": "IN", "payout_rail": "bank",
      "provider_ref": "simrail-Qm9…", "funding_ref": null, "refund_ref": null,
      "created_at": "2026-09-02T10:00:00.000Z", "paid_at": "2026-09-02T10:01:12.345Z",
      "delivered_at": "2026-09-02T10:01:20.000Z", "refunded_at": null }
  ],
  "next_cursor": null,
  "totals": { "count": 1,
              "amount_source_minor_by_currency": { "USD": 20000 },
              "amount_destination_minor_by_currency": { "INR": 1704000 } },
  "window": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-08T00:00:00.000Z" }
}

# The same page as CSV
curl -OJ "$BASE/settlements?from=2026-09-01&to=2026-09-08&format=csv" \
  -H "Authorization: Bearer $KEY"
FieldTypeNotes
referencestringThe transaction id (the same id as /transactions/:id).
statusstringpaid, delivered, or cancelled (only a cancelled transfer that was instructed to a rail).
compliance_statusstringcleared or flagged (a flagged transfer appears once staff released it).
refund_statusstringnone, requested, pending, completed or failed.
amount_source · fee_source · total_charge_sourcenumberMajor units in source_currency.
fx_rate · amount_destinationnumberDestination units per 1 source unit; the payout amount in destination_currency.
source_currency · destination_currency · destination_countrystringISO 4217 / ISO 3166-1 alpha-2.
payout_railstringbank, upi or usdc.
provider_refstring | nullThe rail's settlement reference. On the reference simulator rail it is simulated.
funding_ref · refund_refstring | nullThe funding charge and refund references, when present.
created_at · paid_at · delivered_at · refunded_atstring | nullISO 8601 UTC. For a released compliance hold, paid_at is the release time.

The statement never includes payout account details, recipient or sender identity.

2 · Rates (compete for routed flow)

Push the wholesale conversion rate you offer per corridor with PUT /rates. When your fresh rate beats the platform mid-market rate (and your settlement rail is configured), SmartRemit routes eligible platform transfers to you for settlement. Pushing a rate does not change the pricing of your own /quote or /transactions — those stay at platform mid-market.

PUT /rates — request fields
One corridor per call. Re-push before expiry to stay fresh.
FieldTypeNotes
source_currencystringRequired. ISO 4217 send currency (e.g. USD).
destination_currencystringRequired. ISO 4217 payout currency (e.g. INR); must differ from source.
effective_ratenumberRequired. Destination units per 1 source unit; 0 < rate < 100000.
ttl_secondsnumberOptional. Freshness window — default 3600, clamped to [60, 86400]. An expired rate stops competing.
# Push your USD→INR rate (fresh for 30 minutes)
curl -X PUT https://smartremit.ai/api/partner/v1/rates \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "source_currency": "USD", "destination_currency": "INR",
        "effective_rate": 86.4, "ttl_seconds": 1800 }'

# → 200
{ "source_currency": "USD", "destination_currency": "INR",
  "effective_rate": 86.4, "expires_at": "…", "pushed_at": "…" }

GET /rates returns your sheet: { "rates": [ { source_currency, destination_currency, effective_rate, expires_at, fresh, margin_bps } ] } — fresh tells you whether the pushed rate is still competing; margin_bps is your standing platform-configured fallback margin.

3 · Settlement instructions (us → you)

When a transfer is paid (pay page or /confirm), SmartRemit POSTs a signed instruction to your configured settlement endpoint — with automatic retries and exponential backoff until your rail acks 2xx. Every instruction carries a timestamped signature in the x-smartremit-signature header — verify it (see Signatures below).

POST <your settlementUrl>
x-smartremit-signature: t=1790000000,v1=5d2e…   # recommended — verify this
x-signature: 3f1a…                              # deprecated — HMAC-SHA256 of the raw body

{
  "reference": "tr_abc123",          // OUR transfer id — echo it in callbacks
  "partner_id": "acme",
  "corridor": { "source": "US", "destination": "IN" },
  "payout":   { "rail": "bank", "destination": "123456789012|HDFC0001234" },
  "recipient":{ "name": "Anita Sharma", "phone": "919876543210" },
  "amount": {
    "source": 200, "currency": "USD",
    "destination": 16600, "destination_currency": "INR",
    "fx_rate": 83                     // locked at quote time
  }
}

Respond 2xx with an optional { "providerRef": "…" } — stored write-once against the transfer. Use reference to deduplicate: the instruction is at-least-once.

Ack deadline: 15 seconds. We wait at most 15s for your 2xx; a slower response is treated as a failure and the SAME instruction (same reference) is retried with exponential backoff. Persist and ack first, then process asynchronously — and dedupe on reference, so a retry after a slow ack can never pay out twice.

Endpoint requirements. Your settlementUrl must be a public https:// host on port 443 (no IP literals, no internal or single-label names, no credentials in the URL). We follow at most two redirects, only 307/308 to the same origin — never a 301/302/303, which would turn the signed POST into a GET. Your ack must be 64 KB or less, uncompressed (we send Accept-Encoding: identity and never decompress), and providerRef is at most 128 characters of A–Z a–z 0–9 . _ : -. An endpoint that fails these checks is refused before the instruction is sent: the instruction is retried with backoff and then raises an ops alert, so fix the endpoint in Admin → Partners → Payment and the retries pick it up.

Compliance block (additive)

Instructions also carry a top-level compliance object after the fields above. It is additive and optional: ignore unknown keys, and do not reject an instruction that lacks it (instructions sent by an older release, or where the data could not be loaded, have no block or originator: null). The signatures cover it, like every other byte of the body.

"compliance": {
  "version": 1,
  "originator": {                     // null when routed or unavailable
    "entity_type": "individual",      // or "business" (name = the business)
    "name": "Ravi Kumar", "country": "US", "phone": "15551230000",
    "id_type": "passport", "id_last4": "1234"
  },
  "beneficiary": { "entity_type": "individual", "name": "Anita Sharma",
                   "relationship": "parent", "country": "IN" },
  "purpose": "family_support",
  "purpose_code": null,
  "kyc": { "status": "verified", "tier": "T1", "verified_at": "2026-09-01T12:00:00.000Z" },
  "screening": { "status": "cleared", "reasons": [],
                 "screened_at": "2026-09-23T10:00:00.000Z",
                 "list_source": "…", "list_version": "…", "decision": "clear" },
  "edd_required": false
}
  • Never sent: the full ID number, date of birth, email or residential address. The originator's address is not included yet.
  • purpose is our purpose value. purpose_code (for example an RBI purpose code) is always null for now: a draft, for counsel review.
  • list_source, list_version and decision appear only when a sanctions screening record exists for the transfer.
  • When a transfer is settled on a rail other than the owning partner's, the block carries "routed": true and originator: null: the sender's identity is not shared with another partner.

Signatures (both directions)

x-smartremit-signature: t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, t + "." + rawBody), hex. Use the signingSecret on instructions (us → you) and the webhookSecret on status callbacks (you → us).

signed = t + "." + rawBody            // the exact bytes received
expected = hex(HMAC_SHA256(secret, signed))
valid = |now - t| <= 300 seconds
        AND some v1 in the header equals expected (constant-time compare)
  • Reject anything outside the ±5 minute window, and keep the messages you have already processed (e.g. a hash of t.rawBody) for at least 15 minutes: answer a repeat with 2xx and do nothing. We do the same — a repeated callback gets { "ok": true, "duplicate": true }.
  • Key rotation: when a secret is changed in Admin → Partners → Payment, the old one stays valid for 7 days. During that time our instructions carry one v1 per active secret; accept the message if any of them matches. Your callbacks may be signed with either secret.
  • Deprecated: x-signature (hex HMAC-SHA256(rawBody, secret), no timestamp) is still sent and still accepted when x-smartremit-signature is absent. It will be retired — move to the timestamped header now. When x-smartremit-signature is present it alone decides.

4 · Status webhooks (you → us)

Report lifecycle status to POST /api/payment-webhook/<provider>, signed with your webhookSecret using the timestamped signature (fail-closed: unsigned, mis-signed or out-of-window callbacks are rejected with 401).

POST /api/payment-webhook/acme-rail
x-smartremit-signature: t=1790000000,v1=9c44…   # HMAC-SHA256(webhookSecret, t + "." + rawBody)

{ "reference": "tr_abc123", "status": "paid_out",
  "amount": { "destination": 16600, "destination_currency": "INR" } }

Amount on paid_out: echo the amount.destination and destination_currency from our instruction. If they differ from what we instructed (to the minor unit), the transfer is not delivered: it is held for our ops team, who resolve it with you, and we answer { "ok": true, "held": true } so you stop retrying. A paid_out without an amount is accepted for now but is deprecated.

Status mapping
The state machine is forward-only — replays and out-of-order callbacks are safe.
  • created → awaiting payment (no-op transition)
  • funded → paid (customer charged on your side)
  • paid_out → delivered — triggers the branded WhatsApp delivery notifications
  • failed / returned → cancelled — the sender's charge is refunded (a partner-pulled debit gets a signed reverse instruction) and the customer is notified, in one transaction. An optional reason (string, ≤200 characters) is stored on the transfer's note for your ops and ours.
  • A failed after paid_out is recorded for ops and never reverses a delivery.
  • A paid_out after a failed is refused and alerted — the transfer stays cancelled.
  • A reverse for a debit that never happened must be a no-op on your side.

No rail yet? Point your integration at the hosted reference rail (providerType: simulator) — it verifies your signatures, acks a providerRef, and calls the public webhook back ~12s later, running the exact production loop end to end. To exercise the failure path, pay to a bank account that is all zeros (for India: account 000000000000, IFSC HDFC0001234): the reference rail acks, then reports failed with reason account_unreachable.

5 · Your WhatsApp number & KYC mode

Bring your own Meta WhatsApp Business number: configure the phone-number id, access token, verify token, and app secret in the dashboard, then point Meta's webhook at your dedicated callback URL (shown on your partner page). Inbound messages on your number route to your tenant — replies, OTPs, and delivery notifications leave from your number under your brand.

KYC: run it yourself (delegated mode — you attest verification) or use SmartRemit's built-in tiered KYC. Sanctions screening is not delegable — it always runs on our side and cannot be switched off.

KYC attestation on the API
Your sender.kyc_status is your attestation; SmartRemit trusts it.

Whether an unverified sender may send at all depends on your partner's verify-before-send setting (off unless it was turned on for you), in either KYC mode. The value must be one of the five below, exactly as written (lowercase); any other value, or no value, counts as not_started. Always send it explicitly.

kyc_statusVerify-before-send offVerify-before-send on
verifiedSends.Sends.
grandfatheredSends.Refused (422).
not_started · pendingSends.Refused (422).
rejectedRefused (422).Refused (422).
  • Every new sender starts with a 3-day observation window, counted from when we first see them, even if you attest verified: during it the lower daily limit applies. After it, the higher limit applies to every sender who can send.
  • A transfer over the sender's current limit is refused with 422 This transfer exceeds the sender's current sending limit. (no figures in the response).
  • Sanctions screening runs on every transaction whatever the attestation, sandbox included.
  • Always send sender.name as well: without it the transaction is held for manual review (see above).
Recipient delivery template
The message a recipient gets when their money is delivered.

A recipient usually has no open conversation with the sending number, so this notice is a WhatsApp message template. If you use your own WhatsApp number, create it in your own WhatsApp Business account with exactly this name and language, or every send falls back to a plain text, which only reaches a recipient who messaged your number in the last 24 hours. (On SmartRemit's shared number, SmartRemit's own template is used and there is nothing to submit.)

  • Name transfer_delivered · category Utility · language English (en)
  • Exactly four body variables, in this order:
Hi {{1}}, you've received {{2}} from the sender with phone number {{3}}. It's on its way to your {{4}}.

{{1}} recipient name       sample: Priya
{{2}} amount delivered     sample: ₹4,750
{{3}} sender phone, masked sample: ••••4567
{{4}} payout label         sample: bank account
  • The sender's phone number is always masked to its last 4 digits (for example ••••4567); the recipient never sees the full number.
  • {{4}} is always bank account today, whatever the payout method.
  • Submit the template well before you go live: Meta reviews it before it can be sent.