Skip to content

Webhooks

Moria calls an HTTPS address you own whenever something terminal happens. This is how you find out a customer paid; polling is the fallback, not the design.

Set the address in the console, under Webhook → Alamat pengiriman. Until one is set, events are recorded but sent nowhere.

EventWhen
payment.succeededA customer paid, and the money is yours
payment.failedThe provider refused the payment
payment.expiredThe instrument was never paid in time
payout.succeededA transfer settled
payout.failedA transfer failed; your reservation has been returned
test.pingYou pressed the test button. No money involved

An expiry is confirmed by the provider where possible; where it is not, Moria expires the instrument itself once it is well past expires_at. Either way the event fires, so expires_at is a deadline you can act on rather than a hint.

{
"id": "evt_01J9…",
"type": "payment.succeeded",
"occurred_at": "2026-09-03T07:20:00.000Z",
"partner_id": "54e39b24-…",
"data": {
"reference_number": "INV-2026-0912",
"amount": "100000.0000",
"net_amount": "96000.0000",
"currency": "IDR"
}
}

The payment.succeeded event reports the gross amount and the partner’s net_amount. A payout.succeeded event reports the three payout figures: amount leaving the partner balance, one combined fee, and net_amount reaching the bank. The provider/margin fee split is not sent to partners.

Answer 2xx to acknowledge. Anything else — including a redirect — counts as a failure and will be retried.

Every call carries:

x-moria-signature: t=1788505200,v1=<hex>

v1 is HMAC-SHA256 over the string `${t}.${rawBody}`, keyed by your webhook signing secret.

const [t, v1] = header.split(',').map((part) => part.split('=')[1])
const expected = crypto
.createHmac('sha256', webhookSigningSecret)
.update(`${t}.${rawBody}`, 'utf8')
.digest('hex')
// Constant-time. `===` leaks the answer through how long it takes.
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))

Reject anything that fails verification, and reject a t that is far from now — that is what stops an old, genuine call being replayed at you later.

A delivery that does not get a 2xx is retried with widening gaps, up to ten attempts, then marked exhausted. A 4xx is retried too — a partner mid-deploy answering 404 should not permanently lose events.

Every attempt is visible in the console under Webhook, with the response code and the error, and there is a Kirim ulang button. Pressing it twice is safe: the event id does not change, so your deduplication still holds.

On Webhook → Alamat pengiriman there is Kirim event uji. It sends one test.ping down exactly the same path as a real event — same signature, same retry behaviour, recorded in the same log. If your verification is wrong, this is where you find out, rather than on your first real payment.