Webhooks
Settlement instructions, status callbacks, signatures and retries.
Two directions
- Settlement instructions (us to you). When a transfer is paid, SmartRemit sends a signed instruction to your settlement endpoint telling your rail to pay out.
- Status callbacks (you to us). Your rail reports the transfer's progress back to SmartRemit, signed with your webhook secret.
Both directions use the same timestamped signature, described below.
Settlement instructions
SmartRemit sends a POST to your settlement endpoint:
POST https://partner.example.com/settlements
x-smartremit-signature: t=1790000000,v1=5d2e…
Content-Type: application/json
{
"reference": "tr_abc123",
"partner_id": "acme",
"corridor": { "source": "US", "destination": "IN" },
"payout": { "rail": "bank", "destination": "123456789012|HDFC0001234" },
"recipient":{ "name": "Anita Sharma", "phone": "+12025550143" },
"amount": {
"source": 200, "currency": "USD",
"destination": 16600, "destination_currency": "INR",
"fx_rate": 83
}
}
referenceis our transfer ID. Echo it in your status callbacks.- The instruction is at least once: deduplicate on
reference, so a retry can never pay out twice. - Answer
2xx, optionally with a JSON body carryingproviderRef(your reference, stored once against the transfer). - When you sent a
client_referenceand your own rail pays the transfer out, the instruction carries it asclient_reference. A transfer paid out by another partner's rail never carries it.
Compliance block
Instructions also carry an additive compliance object. Ignore unknown keys, and do not reject an instruction that lacks it.
"compliance": {
"version": 1,
"originator": {
"entity_type": "individual",
"name": "Ravi Kumar", "country": "US", "phone": "12025550142",
"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
}
versionis1.originatorhasentity_type(individualorbusiness; for a business,nameis the business),name,country,phone,id_typeandid_last4.- Never sent: the full ID number, date of birth, email or residential address. The originator's address is not included yet.
purposeis our purpose value.purpose_codeis alwaysnullfor now.screening.list_source,list_versionanddecisionappear only when a sanctions screening record exists for the transfer.- A transfer settled on another partner's rail carries
"routed": trueandoriginator: null: the sender's identity is not shared with another partner.originatoris alsonullwhen it could not be loaded, and older instructions have no block. - The signatures cover the block like every other byte of the body.
Reversals
A cancelled transfer whose funds your rail pulled gets a signed reverse instruction. It has "action": "reverse" and its own reference, distinct from the original instruction's, so your rail can tell a reverse from a replay. A reverse for a debit that never happened must be a no-op on your side.
Ack deadline
We wait at most 15 seconds for your 2xx. A slower answer is treated as a failure and the same instruction is retried. Persist and acknowledge first, then process asynchronously.
Endpoint rules
- A public
https://host on port 443. No IP addresses, no internal or single-label host names, no credentials in the URL. - We follow at most 2 redirects, and only
307or308to the same origin. A301,302or303would turn the signedPOSTinto aGET, so it is refused. - Your acknowledgement must be 64 KB or less, uncompressed: we send
Accept-Encoding: identityand never decompress.providerRefis at most 128 characters of letters, digits and. _ : -.
An endpoint that breaks these rules is refused before the instruction is sent; the instruction is retried, so fix the endpoint on your dashboard (Integrations → Settlement webhooks) and the retries pick it up.
Retries
Instructions are delivered at least once. A failed attempt is retried with exponential backoff: roughly 2^n seconds after the n-th attempt, capped at 1800 seconds between attempts. After 56 attempts (about a day in total) the instruction stops retrying and SmartRemit's operations team is alerted to resolve it with you.
Status callbacks
Report each transfer's status to the callback URL shown on your dashboard's partner page (it has the shape https://smartremit.ai/api/payment-webhook/{provider}), signed with your webhook secret:
POST https://smartremit.ai/api/payment-webhook/acme-rail
x-smartremit-signature: t=1790000000,v1=9c44…
Content-Type: application/json
{ "reference": "tr_abc123", "status": "paid_out",
"amount": { "destination": 16600, "destination_currency": "INR" } }
| Status | Meaning |
|---|---|
created | Awaiting payment (no change). |
funded | Paid: the customer was charged on your side. |
paid_out | Delivered. Triggers the delivery notifications. |
failed | Cancelled. If SmartRemit captured the charge it is refunded; a partner-pulled debit gets a signed reverse; otherwise you return the funds on your side. The customer is told. |
returned | Cancelled, as for failed: if SmartRemit captured the charge it is refunded; a partner-pulled debit gets a signed reverse; otherwise you return the funds on your side. The customer is told. |
- The state machine only moves forward, so replays and out-of-order callbacks are safe.
- A
failedorreturnedmay carry an optionalreasonstring. It is stored on the transfer for your operations team and ours, cut to 200 characters. - A
failedafterpaid_outis recorded for operations and never reverses a delivery. Apaid_outafterfailedis refused and the transfer stays cancelled. - Send your payout confirmation (for example a bank UTR) as
payout_referenceonpaid_out: 1 to 64 letters, digits and. _ : / # -. We save the first one; later ones, and a value that does not fit, are ignored, and the status still applies. - Echo the amount on
paid_out. Send back theamount.destinationanddestination_currencyfrom our instruction. If they differ, the transfer is not marked delivered: it is held for our operations team, who resolve it with you, and we answer with"held": trueso you stop retrying. Apaid_outwith noamountis accepted for now but is deprecated. - Unsigned, mis-signed and out-of-window callbacks are rejected with
401.
Signatures
Every instruction and every callback carries:
x-smartremit-signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( secret, t + "." + rawBody ) )
Use your signing secret to verify our instructions, and your webhook secret to sign your callbacks. To verify:
signed = t + "." + rawBody (the exact bytes received)
expected = hex(HMAC_SHA256(secret, signed))
valid = |now - t| within the tolerance
AND some v1 in the header equals expected (constant-time compare)
- The tolerance is 300 seconds either side of now. Reject anything outside it.
- Compare in constant time.
- Keep a record of the messages you have processed (for example a hash of
t.rawBody) for at least 15 minutes, and answer a repeat with2xxwithout acting on it. We do the same: a repeated callback gets{ "ok": true, "duplicate": true }. - Secret rotation. When a secret is changed, the old one stays valid for 7 days. During that time our instructions carry one
v1per active secret: accept the message if any of them matches. Your callbacks may be signed with either secret. - Deprecated: the older
x-signatureheader (an HMAC of the raw body with no timestamp) is still sent, and still accepted whenx-smartremit-signatureis absent. It will be retired, so verify the timestamped header now. When both are present,x-smartremit-signaturealone decides.