Event reference¶
Each of the 12 events the platform sends: when, after what, who triggers it, what livemode is, which object
data.object is, and an example payload. Find the events your handler needs and read those; read the
Idempotent handler first.
The example payloads are the real bodies from the SDK test vectors (sdk/testdata/vectors.json, generated from
the platform's code), pretty-printed for reading. On the wire <, > and & are written as \u003c, \u003e,
\u0026 — always verify the signature over the raw bytes (Signatures). The three marked
"illustrative" have no vector and are written from the real structures.
The event envelope¶
{
"id": "evt_Vec0000000000000000000001",
"type": "payment_intent.succeeded",
"created_at": "2026-10-22T09:00:00Z",
"livemode": true,
"app": "esim",
"data": { "object": { "…": "…" } }
}
| Field | Meaning |
|---|---|
id |
evt_ + 24 base62 characters. The deduplication key; equal to the Gerege-Event-Id header. Not time-ordered |
type |
One of the 12 below; a minor version may add types — answer 2xx to ones you do not know |
created_at |
When the event was created, UTC, to the second (RFC 3339) |
livemode |
true — real money / affects the public; false — test |
app |
The app's slug |
data.object |
The object that changed — the same shape as the API's answer (PaymentIntent, Refund, …) |
The body is created once, when the change is committed: on a retry, to every endpoint, or arriving late, it still
shows the state at that moment; the Events API answers this very body too. Read the current state
with GET.
Types at a glance¶
type |
Triggered by | data.object |
|---|---|---|
payment_intent.succeeded |
the person (automatic) · your backend (manual capture) | PaymentIntent |
payment_intent.requires_capture |
the person | PaymentIntent |
payment_intent.canceled |
your backend · the person · the job (an app intent's expiry) |
PaymentIntent |
payment_intent.failed |
the person (the ledger refused) · your backend (capture) | PaymentIntent |
payment_intent.claimed |
the person | PaymentIntent |
payment_intent.expired |
the job / a read (a claimless intent's expiry) | PaymentIntent |
refund.succeeded |
your backend · the job | Refund |
refund.failed |
your backend · the job | Refund |
app.status_changed |
the developer · the operator | AppStatus |
consent.revoked |
the person | Consent |
settlement.paid |
the job (settlements the operator switched on) | Settlement |
webhook.test |
the developer (console, /webhook-endpoints/{eid}/test) |
WebhookTest |
Every field of every object: Objects.
payment_intent.succeeded¶
The money has moved to the payee — the signal to hand over the goods.
capture_method: "automatic": after the person confirmed in Gerege App and the hold and the capture succeeded in one go.capture_method: "manual": after your backend calledPOST /payment-intents/{id}/captureand the capture succeeded.status: "succeeded",confirmed_atset.feeandnetare numbers for an app with settlements switched on;nullotherwise.- In test mode there is no ledger and it happens at once (decided by the amount's last two digits — Test amounts).
{
"id": "evt_Vec0000000000000000000001",
"type": "payment_intent.succeeded",
"created_at": "2026-10-22T09:00:00Z",
"livemode": true,
"app": "esim",
"data": {
"object": {
"id": "pi_4fQ2mX8kLp0aZr7TbN3cVw9E",
"object": "payment_intent",
"status": "succeeded",
"amount": 45000,
"currency": "MNT",
"description": "Japan 5GB · 15 хоног <roaming & data>",
"reference": "order_8812",
"user_ref": "u_9f2c4e7a1b3d5f60718293a4b5c6d7e8",
"livemode": true,
"capture_method": "automatic",
"refunded_amount": 0,
"fee": null,
"net": null,
"failure_code": null,
"expires_at": "2026-10-22T09:10:00Z",
"capture_before": null,
"confirmed_at": "2026-10-22T08:59:55Z",
"created_at": "2026-10-22T08:59:00Z",
"metadata": { "package": "JC071" },
"presentment": "app",
"terminal": null,
"sub_merchant": null,
"return_url": null,
"claimed_at": null,
"min_age": null,
"capture_within": null
}
}
}
payment_intent.requires_capture¶
A manual intent's money is held on the person's account — now it is your move: if the goods came out,
POST …/capture; if not, POST …/cancel (releases the hold).
- If you decide nothing before
capture_before, the hold is released automatically and the intent becomesexpired(failure_code: "authorization_expired"; the event ispayment_intent.expiredbelow, orpayment_intent.canceledfor anappintent). capture_withinis the window from confirmation, in seconds (default 604 800 = 7 days).
{
"id": "evt_Vec0000000000000000000002",
"type": "payment_intent.requires_capture",
"created_at": "2026-10-22T09:00:00Z",
"livemode": false,
"app": "esim",
"data": {
"object": {
"id": "pi_8HkWq3ZsT0yLm5Nc2Vb7Xp1R",
"object": "payment_intent",
"status": "requires_capture",
"amount": 45000,
"currency": "MNT",
"description": "Japan 5GB · 15 хоног <roaming & data>",
"reference": null,
"user_ref": "u_9f2c4e7a1b3d5f60718293a4b5c6d7e8",
"livemode": false,
"capture_method": "manual",
"refunded_amount": 0,
"fee": null,
"net": null,
"failure_code": null,
"expires_at": "2026-10-22T09:10:00Z",
"capture_before": "2026-10-29T09:00:00Z",
"confirmed_at": null,
"created_at": "2026-10-22T08:59:00Z",
"metadata": {},
"presentment": "app",
"terminal": null,
"sub_merchant": null,
"return_url": null,
"claimed_at": null,
"min_age": null,
"capture_within": 604800
}
}
}
payment_intent.canceled¶
The intent was canceled — no money moved (or the hold was released). Tell which from data.object.status:
status |
Why |
|---|---|
canceled |
Your backend called POST …/cancel (an unpaid intent, or releasing a manual hold); the person canceled the payment sheet; in test mode an amount ending …02 |
expired |
app presentment (mini apps) only: expires_at passed, or a manual hold's capture_before passed (failure_code: "authorization_expired") |
An app intent's expiry sends payment_intent.canceled so that backends written to OMAS keep working; a claimless
(qr/redirect/terminal) intent sends payment_intent.expired instead.
Example (illustrative — a mini app's intent not paid in time):
{
"id": "evt_3Jd8sKq0Lw5Nz2Xc7Vb9Mp1R",
"type": "payment_intent.canceled",
"created_at": "2026-10-22T09:10:20Z",
"livemode": true,
"app": "esim",
"data": {
"object": {
"id": "pi_Ws5Tq8Lm2Kx0Pz7Rb4Nc9Vd1E",
"object": "payment_intent",
"status": "expired",
"amount": 45000,
"currency": "MNT",
"description": "Japan 5GB · 15 хоног",
"reference": "order_8813",
"user_ref": "u_9f2c4e7a1b3d5f60718293a4b5c6d7e8",
"livemode": true,
"capture_method": "automatic",
"refunded_amount": 0,
"fee": null,
"net": null,
"failure_code": null,
"expires_at": "2026-10-22T09:10:00Z",
"capture_before": null,
"confirmed_at": null,
"created_at": "2026-10-22T09:00:00Z",
"metadata": {},
"presentment": "app",
"terminal": null,
"sub_merchant": null,
"return_url": null,
"claimed_at": null,
"min_age": null,
"capture_within": null
}
}
}
payment_intent.failed¶
A payment attempt failed — failure_code says why (Error codes). A failed intent can
be paid again (OMAS): the person may pick another account and confirm again.
failure_code |
When |
|---|---|
insufficient_funds, limit_exceeded, hold_mismatch, payment_failed |
The ledger refused the hold when the person confirmed |
merchant_unavailable |
The hold was placed but meanwhile the app was suspended or its payout account removed — the hold was released |
capture_failed |
The ledger refused the capture — the hold was released |
test_declined |
Test mode, an amount ending …01 |
When the person picks an account that is not theirs or is closed (account_unavailable), no event is sent: the
intent stays requires_confirmation and the person picks another account.
Example (illustrative — test mode, an amount ending …01):
{
"id": "evt_Lq2Wm8Zr5Tn0Xs3Kd7Pb9Vc1F",
"type": "payment_intent.failed",
"created_at": "2026-10-22T09:01:00Z",
"livemode": false,
"app": "esim",
"data": {
"object": {
"id": "pi_Tz6Hc1Nq8Lv3Rw0Km5Xb2Pd9G",
"object": "payment_intent",
"status": "failed",
"amount": 45001,
"currency": "MNT",
"description": "Japan 5GB · 15 хоног",
"reference": "order_test_1",
"user_ref": "u_9f2c4e7a1b3d5f60718293a4b5c6d7e8",
"livemode": false,
"capture_method": "automatic",
"refunded_amount": 0,
"fee": null,
"net": null,
"failure_code": "test_declined",
"expires_at": "2026-10-22T09:10:00Z",
"capture_before": null,
"confirmed_at": null,
"created_at": "2026-10-22T09:00:00Z",
"metadata": {},
"presentment": "app",
"terminal": null,
"sub_merchant": null,
"return_url": null,
"claimed_at": null,
"min_age": null,
"capture_within": null
}
}
}
payment_intent.claimed¶
A person took a claimless (qr, redirect, terminal) intent in Gerege App — scanned the QR, opened the link,
held the phone to the sticker. The signal to show «Confirm on your phone» on your screen or machine; no money has
moved yet (status: "requires_confirmation").
user_ref(a randomu_…for this one payment) andclaimed_atare set.- An unconfirmed claim lapses after 90 seconds: if somebody else claims it after that, a new
payment_intent.claimedarrives with a newuser_ref. The same person claiming again creates no new event. - Your backend's
POST …/unclaimsends no event (the intent is back touser_ref: null).
{
"id": "evt_Vec0000000000000000000008",
"type": "payment_intent.claimed",
"created_at": "2026-10-22T09:00:00Z",
"livemode": true,
"app": "vending",
"data": {
"object": {
"id": "pi_T3rm1nalVend0000000000Ab",
"object": "payment_intent",
"status": "requires_confirmation",
"amount": 3500,
"currency": "MNT",
"description": "Ус 0.5л",
"reference": "basket_77120",
"user_ref": "u_6b1e0c9d2f4a8e7c3b5d1f0a9e8c7b6d",
"livemode": true,
"capture_method": "manual",
"refunded_amount": 0,
"fee": null,
"net": null,
"failure_code": null,
"expires_at": "2026-10-22T09:05:00Z",
"capture_before": null,
"confirmed_at": null,
"created_at": "2026-10-22T08:59:00Z",
"metadata": {},
"presentment": "terminal",
"terminal": "4b8e6c1a-2d3f-4a5b-9c7d-0e1f2a3b4c5d",
"sub_merchant": "9a7c5e3b-1d2f-4e6a-8b0c-7d9e1f3a5b7c",
"return_url": null,
"claimed_at": "2026-10-22T08:59:40Z",
"min_age": null,
"capture_within": 120
}
}
}
payment_intent.expired¶
A claimless (qr, redirect, terminal) intent ran out of time — claimed or not:
- Nobody paid before
expires_at(failure_code: null;"expired"if it ran out while a confirmation was under way). - A manual hold's
capture_beforepassed, or a capture found the hold already lapsed — the money went back to the person (failure_code: "authorization_expired").
The expiry is recorded by the minute's job, or by any request that reads the intent (GET) — within a minute of
expires_at at most. An app intent does not send this event but payment_intent.canceled (status: "expired").
{
"id": "evt_Vec0000000000000000000009",
"type": "payment_intent.expired",
"created_at": "2026-10-22T09:00:00Z",
"livemode": true,
"app": "kiosk",
"data": {
"object": {
"id": "pi_QrK1osk00000000000000Cd2",
"object": "payment_intent",
"status": "expired",
"amount": 12000,
"currency": "MNT",
"description": "Хураамж",
"reference": null,
"user_ref": null,
"livemode": true,
"capture_method": "automatic",
"refunded_amount": 0,
"fee": null,
"net": null,
"failure_code": null,
"expires_at": "2026-10-22T08:59:59Z",
"capture_before": null,
"confirmed_at": null,
"created_at": "2026-10-22T08:50:00Z",
"metadata": {},
"presentment": "qr",
"terminal": null,
"sub_merchant": null,
"return_url": null,
"claimed_at": null,
"min_age": null,
"capture_within": null
}
}
}
refund.succeeded¶
The refund reached the person's account. POST …/refunds usually answers succeeded at once (and the event goes
out at that moment); if the ledger did not answer, it is 202 pending and the job finishes it later and sends the
event. In test mode, at once.
The intent's refunded_amount and status (partially_refunded / refunded) change, but no
payment_intent.* event is sent — only refund.*. Read the intent's new state with GET.
{
"id": "evt_Vec0000000000000000000003",
"type": "refund.succeeded",
"created_at": "2026-10-22T09:00:00Z",
"livemode": true,
"app": "esim",
"data": {
"object": {
"id": "re_2kLm9Pq4Rs6Tu8Vw0Xy1Za3B",
"object": "refund",
"intent_id": "pi_4fQ2mX8kLp0aZr7TbN3cVw9E",
"amount": 10000,
"status": "succeeded",
"reason": "supplier_failed",
"failure_code": null,
"livemode": true,
"created_at": "2026-10-22T09:00:00Z"
}
}
}
refund.failed¶
The refund was refused — no money moved and the intent's refunded_amount did not change. failure_code:
failure_code |
Meaning, what to do |
|---|---|
insufficient_funds |
Your payout account no longer holds the payment's money (it was spent) — top it up and try again with a new Idempotency-Key |
settlement_shortfall |
An app with settlements on: its clearing balance cannot cover the refund (the money already went out in a settlement) — contact the operator |
refund_rejected |
The ledger refused it for another reason |
Example (illustrative):
{
"id": "evt_Pn4Xq9Lr2Wm7Tz0Kc5Hb8Vd3J",
"type": "refund.failed",
"created_at": "2026-10-22T11:20:00Z",
"livemode": true,
"app": "shop",
"data": {
"object": {
"id": "re_Hk7Ns2Qw9Lm4Xt0Rz5Pc8Vb1D",
"object": "refund",
"intent_id": "pi_Mx3Lq8Tn1Wz6Rk0Pc4Hs9Vb2F",
"amount": 45000,
"status": "failed",
"reason": "customer_request",
"failure_code": "settlement_shortfall",
"livemode": true,
"created_at": "2026-10-22T11:20:00Z"
}
}
}
app.status_changed¶
The app's status or review_status changed: submitted for review, approved, rejected, suspended, maintenance
switched on or off. Written in the same transaction as the change.
livemode: true— the app was published (approved andactive/maintenance) before or after the change, so the public is affected;false— only the developer and test users are (for example a draft going to review).review_note— the operator's note on a rejection or suspension; may benullotherwise.- No immediate attempt — through the job, within a minute (Delivery).
{
"id": "evt_Vec0000000000000000000004",
"type": "app.status_changed",
"created_at": "2026-10-22T09:00:00Z",
"livemode": true,
"app": "esim",
"data": {
"object": {
"object": "app",
"slug": "esim",
"status": "active",
"review_status": "suspended",
"previous_status": "active",
"previous_review_status": "approved",
"review_note": "Хэрэглэгчийн гомдол шалгагдаж байна"
}
}
}
consent.revoked¶
A person disconnected your mini app in Gerege App (withdrew their consent). Every access and refresh token for them
is already revoked — calls with them answer 401 invalid_token. On your side, end that person's session and delete
what you linked to the user_ref according to your privacy policy.
user_ref— the person's pairwisesub(a stable name for them in your app).livemode: true— the app is published;false— only the developer and test users can open it.- Best effort: the disconnection itself always happens; in the rare case the event could not be written it may not arrive. No immediate attempt — through the job.
{
"id": "evt_Vec0000000000000000000005",
"type": "consent.revoked",
"created_at": "2026-10-22T09:00:00Z",
"livemode": true,
"app": "esim",
"data": {
"object": {
"object": "consent",
"revoked_at": "2026-10-22T09:00:00Z",
"user_ref": "u_78a08c3afb344c1b4b765aead45e1693"
}
}
}
settlement.paid¶
A settlement reached your payout account (Settlements). Sent only once the operator has switched settlements on for your app; without that, captures go straight to the payout account and this event never comes.
- Always
livemode: true. paidmay be 0 (all of it fee, or carried to the next settlement).sub_merchant— the sub-merchant's id for a sub-merchant's settlement,nullfor the app's own.- Identities:
net = gross − refunds − fee,paid = net − holdback + released − reserve_refunds − recovered.
{
"id": "evt_Vec0000000000000000000007",
"type": "settlement.paid",
"created_at": "2026-10-22T09:00:00Z",
"livemode": true,
"app": "esim",
"data": {
"object": {
"id": "st_7QwErTy2UiOp4AsDf6GhJk8L",
"object": "settlement",
"status": "paid",
"cutoff": "2026-10-21T16:00:00Z",
"gross": 100000,
"refunds": 5000,
"reserve_refunds": 0,
"fee": 2000,
"net": 93000,
"holdback": 0,
"released": 0,
"recovered": 0,
"paid": 93000,
"intent_count": 3,
"destination_masked": "******0042",
"holdback_release_at": null,
"livemode": true,
"created_at": "2026-10-22T08:58:00Z",
"paid_at": "2026-10-22T08:59:00Z",
"refund_count": 1,
"fee_assessed": 2000,
"fee_returned": 0,
"fee_carried_in": 0,
"fee_carried_out": 0,
"clearing_carried_in": 0,
"clearing_carried_out": 0,
"failure_code": null,
"sub_merchant": null
}
}
}
webhook.test¶
The developer console's «Send test» button (POST /dev/apps/{id}/webhook/test, …/webhook-endpoints/{eid}/test) or
your server's POST /webhook-endpoints/{eid}/test — along the path of a real event (the same signature, outbox and
retries) but to the one endpoint chosen (whatever its filter or status), and always livemode: false, even to a
live endpoint. It cannot be named in an event_types filter and never appears in the Events API. It
replaces OMAS's ping. Do nothing with it — answering 2xx is enough.
{
"id": "evt_Vec0000000000000000000006",
"type": "webhook.test",
"created_at": "2026-10-22T09:00:00Z",
"livemode": false,
"app": "esim",
"data": {
"object": {
"message": "Gerege App webhook test",
"object": "webhook_test"
}
}
}
Compatibility¶
- Additions, not removals: a minor version may add event types and new fields on objects. Answer
2xxto an unknowntypeand ignore unknown fields (do not reject the JSON with a strict schema). - Every
payment_intent.*event'sdata.objecthaspresentment,terminalandsub_merchant("app",null,nullfor anappintent). - A backend written to OMAS: the
OpenPay-Signature/OpenPay-Event-Idheaders arrive with the same values, and anappintent's expiry ispayment_intent.canceled, as in OMAS.