Webhooks
Polling GET /v1/intents/{id} is fully supported and always will be. Webhooks
are additive: the same decision, pushed instead of pulled.
Set the URL and mint a signing secret in Settings. You need both — a URL
without a secret sends nothing, on purpose, because an unsigned payment
notification is not worth having. The secret looks like whsec_ followed by 64
hex characters and is shown exactly once.
The URL must be https, must carry no credentials in it, and must not resolve
to a private address.
The eight events
Section titled “The eight events”There are eight, and there will not quietly be a ninth.
| event | when |
|---|---|
payment.confirmed |
An order is paid. Whatever the evidence — including a late payment under the default policy, which carries "late": true. |
payment.late |
Only when your late policy is park. A payment arrived after its reservation lapsed and we did not confirm it for you. |
payment.unplaced |
Money landed that matched no reservation at all. |
payment.ambiguous |
More than one order could have been the one. We will not choose. |
payment.duplicate |
The same payer paid the same amount within the last 30 minutes. |
payment.stale |
The credit arrived with no usable timestamp, or more than 30 minutes late. We never match on the time we happened to receive something. |
intent.expired |
An order ran past its expires_at unpaid. |
intent.cancelled |
An order was cancelled, by you or from the dashboard. |
Two payload shapes, one discriminator
Section titled “Two payload shapes, one discriminator”Every payload starts with event, event_id and occurred_at, in that order.
Intent-scoped — payment.confirmed, intent.expired, intent.cancelled —
then carries the exact body of GET /v1/intents/{id}, spread at the top level:
{ "event": "payment.confirmed", "event_id": "evt_3a1c90ff42b7e6d5081c4a2b", "occurred_at": 1758211343, "intent_id": "int_9f2c1a77b40e6d3a5c81", "status": "confirmed", "list_paise": 49900, "page_url": "https://moneylanded.com/p/4f0a9c2b7e1d8536a0b4c9de1f327a65", "expires_at": 1758297600, "paid_paise": 49899, "discount_paise": 1, "evidence": "amount", "rrn": "530112345678", "payer_vpa": "asha@ybl", "payer_name": "ASHA KUMARI", "confirmed_at": 1758211343}Credit-scoped — the other five — carries the parked credit and the orders it might have belonged to:
{ "event": "payment.unplaced", "event_id": "evt_7d20b8c1ee45093f6a1b2c3d", "occurred_at": 1758212042, "intent_id": null, "credit": { "rrn": "530198765432", "amount_paise": 50000, "payer_vpa": "asha@ybl", "payer_name": "ASHA KUMARI", "source": "email", "credited_at": 1758212001, "status": "unplaced", "note": "no reservation at this amount" }, "candidates": []}intent_id is your discriminator. It is a string on the three
intent-scoped events and explicitly null on the five credit-scoped ones.
Switch on that, not on whether a credit key happens to exist. This is the one
deliberate null in the whole API — everywhere else an absent value means an
absent key.
if (payload.intent_id === null) { // a parked credit: payload.credit and payload.candidates} else { // an order: the GET /v1/intents/{id} body, at the top level}One more thing worth knowing: candidates here is an array of full order
objects, so you can render a choice without a call per candidate. The
candidates field on GET /v1/credits is an array of ids. Same word, two
shapes, and this is the only place they differ.
The signature
Section titled “The signature”Every request carries:
X-ML-Signature: t=1758211343,v1=8f1b...64 hex characters...t is unix seconds at the moment we sent this attempt. v1 is
lowercase-hex HMAC-SHA-256 over:
<t> + "." + <the exact request body bytes>The key is the raw UTF-8 of your secret string, including the whsec_
prefix — do not strip it and do not hex-decode it. Sign the raw body you
received, before any JSON parsing and re-serialising; a re-serialised body has
different bytes and will not verify. Field order, whitespace, unicode escaping
— any of it can change under a parse-and-rebuild round trip, so the one body
that is guaranteed to verify is the one nobody touched.
The dot matters. It is what stops t=17000000 with a body starting 00. from
producing the same signed material as t=1700000000 with the real body.
t is inside the signed material so you can reject a replay, and this is
the check people skip. HMAC has no notion of time — a signature computed
once is valid forever, so a verifier that only checks v1 cannot tell a fresh
delivery from somebody replaying a capture of a real one back at you next
week. What buys you replay protection is checking t against your own clock
and refusing anything too old. The reference verifiers below reject anything
more than 300 seconds off. That number works because every attempt — the
first try or the sixth retry — mints a fresh t at the moment it is actually
sent, so a legitimate delivery is always close to now regardless of which
retry tier produced it; 300 s leaves room for clock drift and a slow proxy
without leaving a captured request usefully replayable for long. And compare
the two digests in constant time — crypto.timingSafeEqual in Node,
hmac.compare_digest in Python — never with == or ===. A plain string
comparison returns as soon as it finds the first differing byte, and an
attacker who can measure that timing can recover your signature one byte at a
time.
A known-answer vector
Section titled “A known-answer vector”Check your implementation against this before you trust it. With
secret = whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeft = 1700000000body = {"event":"payment.confirmed","event_id":"evt_000102030405060708090a0b","occurred_at":1700000000,"intent_id":"int_0123456789abcdef0123"}the signature is
v1 = bc33d852f8e632a5591881453a245169585f494f40f596dcf4db83389abef180From a shell:
printf '%s' '1700000000.{"event":"payment.confirmed","event_id":"evt_000102030405060708090a0b","occurred_at":1700000000,"intent_id":"int_0123456789abcdef0123"}' \ | openssl dgst -sha256 \ -hmac 'whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' -rVerifying, in Node
Section titled “Verifying, in Node”import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/** @param rawBody the request body as bytes or a string — never a re-serialised object. */export function verify(rawBody, header, secret) { const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? ''); if (!m) return false;
const [, t, given] = m;
// Reject a stale timestamp. This is what stops somebody replaying a capture // of a real delivery at you next week: the signature is still valid, because // it always will be, and only `t` tells you how old it is. if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${t}.`) .update(rawBody) .digest();
const got = Buffer.from(given, 'hex'); return got.length === expected.length && crypto.timingSafeEqual(got, expected);}Wire it up so the raw body actually survives — in Express that means
express.raw, not express.json:
app.post('/webhooks/moneylanded', express.raw({ type: 'application/json' }), (req, res) => { if (!verify(req.body, req.get('x-ml-signature'), process.env.ML_WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge first, work afterwards. Anything that is not a 2xx is a retry. res.sendStatus(200); queue.add(event);});Verifying, in Python
Section titled “Verifying, in Python”import hashlib, hmac, re, time
TOLERANCE_SECONDS = 300_HEADER = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")
def verify(raw_body: bytes, header: str | None, secret: str) -> bool: m = _HEADER.match(header or "") if not m: return False t, given = m.group(1), m.group(2) if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode("utf-8"), f"{t}.".encode("utf-8") + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, given)hmac.compare_digest and crypto.timingSafeEqual are there for a reason. A
plain == on a hex string leaks how much of the signature you got right.
Delivery, honestly
Section titled “Delivery, honestly”The first attempt goes out as soon as we decide the event — not on a schedule, and never on the critical path of confirming a payment.
If it does not get a 2xx, we retry. Six attempts in total. The schedule is:
| attempt | when |
|---|---|
| 1 | immediately |
| 2 | 1 second later |
| 3 | 5 seconds later |
| 4 | at least 30 seconds later |
| 5 | at least 120 seconds later |
| 6 | at least 600 seconds later |
Attempts 1 to 3 run inline and hit those numbers exactly. Attempts 4, 5 and 6 are drained by a job that runs every five minutes, so each lands on the first run at or after it is due — “at least 30 seconds” can be four minutes. The number we schedule for is a not-before, not a promise. We would rather tell you that than print a number we do not hit.
Do not build anything that depends on a delivery arriving at a particular
second. Check the t in the signature for staleness; do not infer the age of
an event from which retry you think you are looking at.
Some specifics:
- Anything that is not a
2xxis a failure and retries, including a410and including a redirect. We do not follow redirects. - A timeout is 5 seconds. Acknowledge fast and do your work afterwards.
event_idis your idempotency key. One id per logical event, the same across all six attempts and across any number of replays. Deduplicate on it.- The
User-Agentismoneylanded-webhooks/1, so you can pick our requests out of your own logs. It means nothing else.
Replay is for a delivery that never landed, not a history API
Section titled “Replay is for a delivery that never landed, not a history API”After the sixth attempt, the delivery stops and waits for you in Settings, in the deliveries list, next to a Send again button. Three things worth knowing about it:
- It only exists for a delivery that has not yet succeeded. The moment we
get a
2xxfrom your endpoint, that delivery is settled — the list stops showing it, and there is no way to ask us to send it again. What you can replay is a delivery that is still mid-ladder (“stop waiting, try now” is a reasonable thing to want) or one that exhausted all six attempts. This is not a way to re-fetch something you already received; for that, callGET /v1/intents/{id}or keep your own record when you first process an event. - The button is rate-limited, the same as every other route in this service that can point an outbound request at an address you chose. Being signed in bounds who can trigger a delivery, never how many — the rate limit is what actually bounds that.
- A replay carries the same
event_idit always had. It is a retry of the same event, not a new one — see above, dedupe on it.
Privacy, and what a replay will and will not contain
Section titled “Privacy, and what a replay will and will not contain”We delete the payer’s name and UPI handle from parked credits after 90 days,
and the stored webhook payloads are pruned on the same schedule. So replaying a
parked-credit delivery from four months ago hands you payer_vpa: null and
payer_name: null. That is deliberate — a replay is not a way to bring back
identity we already deleted.
Confirmed orders keep payer details indefinitely, because those are your receipt.
A worked receiver
Section titled “A worked receiver”const handled = new Set(); // in production: a table with a unique index on event_id
app.post('/webhooks/moneylanded', express.raw({ type: 'application/json' }), (req, res) => { if (!verify(req.body, req.get('x-ml-signature'), process.env.ML_WEBHOOK_SECRET)) { return res.sendStatus(400); } res.sendStatus(200);
const e = JSON.parse(req.body.toString('utf8')); if (handled.has(e.event_id)) return; handled.add(e.event_id);
if (e.intent_id === null) { // Parked. Nobody is going to fix this except a human looking at // /docs/quickstart/#4-look-at-what-did-not-land-cleanly or the dashboard. return notifyOps(e.event, e.credit, e.candidates); }
if (e.event === 'payment.confirmed') return fulfil(e); if (e.event === 'intent.expired' || e.event === 'intent.cancelled') return releaseStock(e);});