Skip to content

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.

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.

Every payload starts with event, event_id and occurred_at, in that order.

Intent-scopedpayment.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.

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 timecrypto.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.

Check your implementation against this before you trust it. With

secret = whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
t = 1700000000
body = {"event":"payment.confirmed","event_id":"evt_000102030405060708090a0b","occurred_at":1700000000,"intent_id":"int_0123456789abcdef0123"}

the signature is

v1 = bc33d852f8e632a5591881453a245169585f494f40f596dcf4db83389abef180

From a shell:

Terminal window
printf '%s' '1700000000.{"event":"payment.confirmed","event_id":"evt_000102030405060708090a0b","occurred_at":1700000000,"intent_id":"int_0123456789abcdef0123"}' \
| openssl dgst -sha256 \
-hmac 'whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' -r
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);
});
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.

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 2xx is a failure and retries, including a 410 and including a redirect. We do not follow redirects.
  • A timeout is 5 seconds. Acknowledge fast and do your work afterwards.
  • event_id is 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-Agent is moneylanded-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 2xx from 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, call GET /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_id it 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.

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);
});