> ## Documentation Index
> Fetch the complete documentation index at: https://docs.feddi.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> The Feddi event catalog, the event envelope, and the HMAC-SHA256 signature you verify on every delivery. Reconcile settled activity today through the transactions read API.

## Overview

A Feddi event reports a domain change that already committed: a wallet balance moved, a top-up settled, a promotional grant accrued. Each event carries a stable `event_id` you dedup on and a signature you verify before you trust the body.

Webhooks are server-to-server deliveries: Feddi makes an HTTP POST to your endpoint with the event envelope in the body and the signature in a header. The envelope and signature contract is stable to build against now, even before outbound delivery is mounted.

This page is the contract you build your receiver against: the event catalog, the event envelope, and the signature-verification steps. All are stable. Build your verifier now, validate it against your own test vectors, and you are ready to receive deliveries the moment they are available to you.

<Info>
  Outbound delivery endpoints are not yet mounted. Until they ship, reconcile settled activity through
  the transactions read API at [Transactions](/guides/transactions). The envelope and signature
  contract on this page is what you verify when delivery becomes available, so you can write the
  receiver against it today.
</Info>

## The event envelope

Every event carries the same envelope. Process each event idempotently on its `event_id`: the same event can arrive more than once, so dedup before you act.

<ResponseField name="event_id" field-type="string" required="true">
  Globally unique. This is your idempotency key for processing. Store it, and treat a second arrival
  of the same `event_id` as a no-op.
</ResponseField>

<ResponseField name="event_type" field-type="string" required="true">
  The domain change, for example `wallet.balance_changed`, `topup.confirmed`, or
  `promo_grant.accrued`. Branch your handler on this value.
</ResponseField>

<ResponseField name="occurred_at" field-type="string" required="true">
  RFC3339 timestamp of when the underlying domain change happened.
</ResponseField>

<ResponseField name="emitted_at" field-type="string" required="true">
  RFC3339 timestamp of when Feddi emitted the event, always after the database transaction committed.
  An event never precedes the change it describes.
</ResponseField>

<ResponseField name="merchant_id" field-type="string" required="true">
  The Feddi merchant tenant this event belongs to.
</ResponseField>

<ResponseField name="data" field-type="object" required="true">
  The event-type-specific payload. Read the fields your handler needs by `event_type`.
</ResponseField>

```json theme={null}
{
  "event_id": "evt_9b2c41",
  "event_type": "topup.confirmed",
  "occurred_at": "2026-06-05T10:14:55Z",
  "emitted_at": "2026-06-05T10:14:55Z",
  "merchant_id": "11111111-1111-1111-1111-111111111111",
  "data": { "wallet_id": "wal_77", "credited_actual_minor": 5000, "currency": "QAR" }
}
```

Money inside `data` always follows the same shape: a minor-units integer paired with an explicit ISO-4217 currency, for example `credited_actual_minor: 5000` with `currency: QAR`.

## Event catalog

These are the event types Feddi emits, grouped by domain, with when each one fires.

### Identity & enrollment

| event\_type                | When it fires                                          |
| -------------------------- | ------------------------------------------------------ |
| `customer.identified`      | A known wallet is resolved at the POS.                 |
| `customer.enrolled`        | A new customer is created.                             |
| `enrollment.pending_proof` | A credit lands for an unverified customer and is held. |
| `enrollment.verified`      | Phone proof completes; held promo releases.            |
| `enrollment.expired`       | A pending-proof window lapses.                         |

### Money & wallet

| event\_type              | When it fires                  |
| ------------------------ | ------------------------------ |
| `wallet.balance_changed` | Any balance delta.             |
| `topup.requested`        | A top-up is requested.         |
| `topup.confirmed`        | A top-up settled and credited. |
| `topup.failed`           | A top-up did not settle.       |
| `payment.completed`      | A wallet debit landed.         |
| `payment.refunded`       | A wallet payment was refunded. |
| `payment.reversed`       | A wallet payment was reversed. |

### Promotion & incentive

| event\_type               | When it fires                        |
| ------------------------- | ------------------------------------ |
| `promo_grant.accrued`     | A promotional grant accrued.         |
| `promo_grant.released`    | A held promotional grant released.   |
| `promo_grant.expired`     | A promotional grant expired.         |
| `promo_grant.clawed_back` | A promotional grant was clawed back. |
| `offer.armed`             | An offer is armed for a customer.    |
| `offer.redeemed`          | An offer is redeemed.                |
| `offer.expired`           | An offer expires unredeemed.         |

### Lifecycle & reconciliation

| event\_type              | When it fires                                                             |
| ------------------------ | ------------------------------------------------------------------------- |
| `transaction.settled`    | A transaction settled.                                                    |
| `transaction.disputed`   | A transaction is disputed.                                                |
| `transaction.unattached` | A previously attached receipt or transaction is detached from a customer. |

Every payload follows the same envelope; branch your handler on `event_type` and read the fields your handler needs.

## Delivery model

How deliveries behave on the wire, so your receiver tolerates the realities of an at-least-once channel.

* **At-least-once delivery.** The same event can arrive more than once. Dedup on `event_id`.
* **Per-resource ordering.** A monotonic `sequence_number` on the envelope orders events within a single resource. Do not assume global order across resources.
* **Retry with backoff.** Any non-2xx response or timeout is retried with exponential backoff, then dead-lettered after retries are exhausted.
* **Replay window.** The signed timestamp enforces a replay window. Reject anything outside it before you read the body.
* **Idempotent processing.** Key your processing on `event_id` so a redelivery is a no-op.
* **Endpoint auto-disable.** Sustained delivery failure auto-disables the endpoint. Re-enable it from the dashboard.

The delivery sequence:

```mermaid theme={null}
sequenceDiagram
    participant Feddi as Feddi Partner API
    participant EP as Your endpoint
    Feddi--)EP: POST event (envelope + Feddi-Signature)
    EP->>EP: verify signature, dedup on event_id
    EP-->>Feddi: 2xx ack
    Note over Feddi,EP: non-2xx or timeout, Feddi retries with backoff, then dead-letters
```

## Subscriptions

This is a forward contract, not yet mounted. When delivery ships, you will:

* Register a delivery endpoint URL per integration.
* Receive a per-subscription signing secret.
* List and disable subscriptions.
* Rotate the signing secret.

Until then, reconcile through the transactions read API.

## Verify every delivery

Feddi signs each delivery with HMAC-SHA256 over a canonical base string, keyed by your subscription secret. Verify the signature and reject anything outside the replay window before you read the body. An unsigned or stale request is not a Feddi event.

The signature travels in the `Feddi-Signature` header as `t=<unix-timestamp>,v1=<hex-hmac>`. The signing base string is the signed timestamp, a literal `.`, then the exact raw request body:

```text theme={null}
signing_base = "<timestamp>.<raw-json-body>"
signature    = HMAC_SHA256(secret, signing_base)   // lowercase hex
```

<Steps>
  <Step title="Read the header">
    Parse `Feddi-Signature`. Take `t` (the signed Unix timestamp) and `v1` (the expected hex
    signature).
  </Step>

  <Step title="Reject stale timestamps">
    If `t` is outside your replay window (compare against your own clock), reject the request. This is
    what stops a captured payload from being replayed against you later. We recommend a tolerance of
    300 seconds or less.
  </Step>

  <Step title="Recompute and compare">
    Build `t + "." + raw_body`, compute HMAC-SHA256 with your stored secret, and compare to `v1` using
    a constant-time equality check. Sign the raw bytes you received, never a re-serialized object: any
    whitespace or key-order change breaks the match.
  </Step>

  <Step title="Process idempotently, then 2xx">
    On a match inside the window, dedup on `event_id`, act, and return a `2xx`. A non-2xx response (or
    a timeout) signals failure to Feddi.
  </Step>
</Steps>

<CodeGroup>
  ```python verify.py theme={null}
  import hashlib
  import hmac
  import time

  def verify(headers, raw_body: bytes, secret: str, tolerance_s: int = 300) -> bool:
      parts = dict(p.split("=", 1) for p in headers["Feddi-Signature"].split(","))
      timestamp, provided = parts["t"], parts["v1"]

      if abs(time.time() - int(timestamp)) > tolerance_s:
          return False  # outside the replay window

      signing_base = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signing_base, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, provided)  # constant-time
  ```

  ```json rejected theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "INVALID_SIGNATURE",
      "message": "Signature did not match or timestamp is outside the replay window."
    },
    "meta": { "request_id": "req_evt1", "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

<Warning>
  Compare with a constant-time function (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in
  Node). A plain string equality check leaks timing information that can be used to forge a signature.
</Warning>

<Expandable title="Why sign the raw body, not the parsed object">
  HMAC is computed over exact bytes. If you parse the JSON and re-serialize it before signing, your
  byte stream will differ from what Feddi signed (whitespace, key ordering, numeric formatting), and
  the comparison fails on a legitimate event. Capture the raw request body before any JSON middleware
  touches it, and feed those bytes to the verifier.
</Expandable>

## Reconcile through the transactions read API

Until delivery endpoints are available, treat the transactions read API as your source of truth for what settled. Poll it on a schedule, or pull it on demand to confirm an order reconciled.

* List with `GET /{integrationId}/transactions`, cursor-paginated and filterable by `status`, a date window, and `branch_id`.
* Read one with `GET /{integrationId}/transactions/{transactionId}`.

Both reads are tenant-scoped to the integration in the path and your `x-api-key`. A mismatched `{integrationId}` resolves to `401` or `403`, never another tenant's rows.

See [Transactions](/guides/transactions) for the full projection fields each read returns.

## Where to go next

<Columns cols="2">
  <Card title="Transactions" href="/guides/transactions" icon="receipt">
    The settlement reads you reconcile against today, with their full projection fields.
  </Card>

  <Card title="Idempotency & errors" href="/concepts/idempotency-and-errors" icon="repeat">
    Dedup on event\_id the same way you key mutating calls, and handle every typed error code.
  </Card>

  <Card title="Conventions" href="/api-reference/conventions" icon="book-open">
    The response envelope, money-as-minor-units, and the meta block on every response.
  </Card>

  <Card title="Authentication" href="/api-reference/authentication" icon="key">
    The x-api-key your reconciliation reads authenticate with.
  </Card>
</Columns>
