NyukiAIPayment

Documentation

Take your first payment.

Everything below is the real contract: the routes, the statuses, the failure codes, and the rules a correct integration follows.

Event catalogue

Generated from the same module the emitter is typed against, so it cannot list an event nothing sends. Your dashboard serves the same list, with a sample payload for each.

typeWhen it firesState
payment_intent.createdA payment was created and is waiting to be confirmed.
payment_intent.processingThe payment was routed to a provider and is under way.
payment_intent.requires_actionThe customer has to approve a prompt, enter a code or finish a redirect.
payment_intent.succeededThe funds are confirmed. Fulfil the order here.
payment_intent.failedTerminal failure, with a canonical code saying why.
payment_intent.canceledCancelled before it completed.
payment_intent.expiredThe customer never completed it in time.
charge.succeededOne attempt took the money.
charge.failedOne attempt was declined. The payment may still succeed elsewhere.
refund.succeededA reversal reached the provider and settled.
refund.failedThe provider refused the reversal. Nothing was booked.
dispute.createdA payer’s bank pulled the money back. The amount is held.
payout.paidA withdrawal to your own account settled.Not yet emitted
balance.updatedYour available balance changed.Not yet emitted

The payload

One envelope for every event: an id to deduplicate on, a type to branch on, the environment, and the object itself under data.object. Amounts are decimal strings and dates are epoch milliseconds, exactly as everywhere else.

{
  "id": "wd_01JAY7BQ2M4X8ND6VKQ0R3PZ5W",
  "object": "event",
  "type": "payment_intent.succeeded",
  "created": 1785869000,
  "environment": "TEST",
  "data": {
    "object": {
      "id": "pi_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
      "object": "payment_intent",
      "status": "SUCCEEDED",
      "amount": "500000",
      "amountCaptured": "500000",
      "currency": "XAF",
      "country": "CM",
      "methodType": "MOBILE_MONEY",
      "provider": "SIMULATOR",
      "flowKind": "DEVICE_PUSH",
      "reference": "order-4471",
      "created": 1785869000000
    }
  }
}

Verifying the signature

The NyukiAI-Signature header carries a timestamp and an HMAC over "{timestamp}.{raw body}". Verify it before you trust a single field.

# NyukiAI-Signature: t=1785869000,v1=5257a869e7…
#
# signed_payload = "{t}.{raw_body}"
# expected       = HMAC-SHA256(endpoint_secret, signed_payload)
#
# Compare in constant time, and reject anything where |now - t| is more than
# five minutes. Without the timestamp in the signed string, a captured request
# would verify forever.
#
# During a secret rotation BOTH the new and the previous secret sign, so you
# can deploy the new one without a synchronised cutover.

Four rules that are not optional

Every integration that has ever gone wrong here went wrong on one of these.

  • Fulfil on payment_intent.succeeded from the webhook, never on the HTTP response to confirm. A device-push payment is not complete when that call returns.
  • Expect duplicates. Delivery is at-least-once — deduplicate on the envelope id.
  • Expect events out of order. Compare against your own stored state rather than assuming a sequence.
  • Verify the signature against the raw bytes. A parsed and re-serialised body will not match the HMAC, and that mistake is invisible until production.
  • Answer 2xx quickly and do your work afterwards. Anything else is counted as a failure, and twenty consecutive failures disables the endpoint.

Replaying an event

Any past event can be sent again from Developers → Events, either to every subscribed endpoint or to one that missed it.

{
  "id": "wd_01JB0C4XN2Q8T5R7YH3MF6PW9K",
  "object": "event",
  "type": "payment_intent.succeeded",
  "created": 1785872600,
  "environment": "TEST",
  "replay": {
    "of": "wd_01JAY7BQ2M4X8ND6VKQ0R3PZ5W",
    "requestedAt": 1785872600000
  },
  "data": { "object": { "…": "unchanged" } }
}