Skip to content

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 called POST /payment-intents/{id}/capture and the capture succeeded.
  • status: "succeeded", confirmed_at set. fee and net are numbers for an app with settlements switched on; null otherwise.
  • 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 becomes expired (failure_code: "authorization_expired"; the event is payment_intent.expired below, or payment_intent.canceled for an app intent).
  • capture_within is 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 random u_… for this one payment) and claimed_at are set.
  • An unconfirmed claim lapses after 90 seconds: if somebody else claims it after that, a new payment_intent.claimed arrives with a new user_ref. The same person claiming again creates no new event.
  • Your backend's POST …/unclaim sends no event (the intent is back to user_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_before passed, 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 and active/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 be null otherwise.
  • 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 pairwise sub (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.
  • paid may 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, null for 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 2xx to an unknown type and ignore unknown fields (do not reject the JSON with a strict schema).
  • Every payment_intent.* event's data.object has presentment, terminal and sub_merchant ("app", null, null for an app intent).
  • A backend written to OMAS: the OpenPay-Signature / OpenPay-Event-Id headers arrive with the same values, and an app intent's expiry is payment_intent.canceled, as in OMAS.