# Webhooks guide

Webhooks carry every asynchronous decision in this integration. There are two
streams, and this page covers both.

| Stream | Receiver | Operation |
| --- | --- | --- |
| **Nexa to Tesser**, credit decisions and payout failures | Tesser <T /> | [`POST /v1/webhooks/nexa/{organizationId}`](/api-tesser/webhooks#receive-nexa-webhook-op-4) |
| **Tesser to Nexa**, withdrawal status for treasury | Nexa <N /> | [`POST /v1/webhooks/tesser`](/api-nexa/webhooks#receive-tesser-webhook-nexa-implements-this) |

## Nexa → Tesser

### Envelope

Every Nexa→Tesser event uses the same envelope:

```json
{
  "event_id": "f31c9a4e-6b2d-4a91-9f0e-2c3b7d5a1e44",
  "event_type": "quote.credit.confirmed",
  "occurred_at": "2026-10-14T15:30:02Z",
  "data": { }
}
```

- `event_id` is a UUID minted by Nexa. **It is the dedupe key.**
- `occurred_at` is RFC 3339 UTC.
- `data` varies by `event_type`, below.

### Event catalogue

| `event_type` | Meaning | `data` |
| --- | --- | --- |
| `quote.credit.sent` | Nexa decided to credit; instruction submitted to core banking | `tesser_quote_id`, `quote_status: "CREDIT_SENT"`, `gtq_amount` |
| `quote.credit.confirmed` | GTQ available in Felix's Nexa account | `tesser_quote_id`, `quote_status: "CREDIT_CONFIRMED"`, `gtq_amount` (authoritative, and may deviate from `quoted_gtq` per Nexa's decision), `credited_at` |
| `quote.additional_funds_requested` | Nexa wants more USDC before crediting | `tesser_quote_id`, `quote_status: "DEPOSIT_REPORTED"`, `usdc_total_reported`, `usdc_additional_requested`, `note` (free text) |
| `quote.rejected` | Nexa declines to credit | `tesser_quote_id`, `quote_status: "REJECTED"`, `reason_code` (`RISK`, `AMOUNT` or `OTHER`), `note` |
| `payment.status.changed` | The payout **failed or was returned** after Nexa already answered `COMPLETED`. Sent only on failure; there is no success event | `tesser_quote_id`, `nexa_payment_id`, `payment_status` (`FAILED` or `RETURNED`), `quote_status`, `status_metadata` (free-form object: rail references, failure reasons) |

The payout itself is **synchronous**: `POST /v1/payments` returns the
outcome, so a successful payout produces no event at all. See
[4 · GTQ payout](/flow-nexa/gtq-payout).

`status_metadata` appears only on `payment.status.changed`, where rail
references and failure reasons genuinely vary.

### Delivery contract

**At-least-once.** Tesser dedupes on `event_id`. Nexa retries non-2xx with
exponential backoff, proposed up to 24 hours. Nexa's actual sender policy is
[open question 6](/open-questions).

**Success = any 2xx with an empty body.** Non-2xx or a timeout means retry.

**Ordering is not guaranteed. The reachability rule (state advancement
only) applies.** The [state machine](/flow-nexa/lifecycle) defines which states are
reachable from which. For the *state-advancement* aspect of each event, the
receiver compares the event's `quote_status` to the current state:

| Relationship of the event's `quote_status` to the current state | Receiver behaviour |
| --- | --- |
| **Forward-reachable** from current | Apply it. This is a genuine advance. |
| The **current state itself**, or an **ancestor** | Ack `200`, change nothing. Stale duplicate or reorder. |
| A **different branch or a conflicting terminal**, for example `quote.credit.sent` after `quote.rejected`, or `PAYMENT_FAILED` after `REJECTED` | Ack `200` **and raise an ops alert.** This is a data-integrity incident between the two companies, not a reordering. Silently ignoring it would hide real money divergence. |

**The late-failure event is not a conflict.** `PAYMENT_FAILED` and
`PAYMENT_RETURNED` are *forward-reachable* from `PAYMENT_COMPLETED`, so a
failure arriving after a successful payout response is handled by the normal
forward-reachable clause: apply it, no alert. It is the expected late-failure
path, and it is the only payout event there is.

**Non-state-advancing events are still processed.** Some events carry an
action or new information without moving the state machine:
`quote.additional_funds_requested` (the quote stays `DEPOSIT_REPORTED`, but
Tesser must act) and any `payment.status.changed` that repeats a failure
already recorded. These are processed on the **first delivery of each `event_id`**;
later deliveries of the same `event_id` are deduped as usual. The
reachability rule governs only the `quote_status` field, never whether the
event's content is handled.

### Authentication

Nexa presents its client certificate; Tesser resolves Nexa's identity from
the registered fingerprint, and the resolved identity must match the
`{organizationId}` in the path. Mismatch is `401`. Full detail on
[Authentication](/authentication).

Connectivity and credentials can be checked without sending an event:
[`HEAD /v1/webhooks/nexa/{organizationId}`](/api-tesser/webhooks#verify-webhook-connectivity-op-4)
<T />.

## Tesser → Nexa

Nexa hosts
[`POST /v1/webhooks/tesser`](/api-nexa/webhooks#receive-tesser-webhook-nexa-implements-this)
<N /> to receive Tesser's **standard outbound webhooks**. In this POC the
subscription Nexa needs is **withdrawal status updates**, supporting
[`POST /v1/treasury/withdrawals`](/api-tesser/treasury#create-withdrawal)
<T /> in [phase 5](/flow-nexa/treasury).

The envelope and event types are Tesser's existing ones. This portal
documents *which* subscription Nexa needs and links to
<a href="https://docs.tesser.xyz" target="_blank">docs.tesser.xyz</a> for the
event-type strings rather than re-specifying a contract that already exists
and is already live elsewhere.

Delivery auth: Ed25519 `X-Tesser-Signature` today, plus a Tesser client
certificate once the sender's outbound-mTLS work lands. See
[Authentication](/authentication).

## Checklist for implementers (both directions)

1. **Dedupe on the event id** before doing anything else. At-least-once means
   duplicates are normal traffic, not an incident.
2. **Ack fast, process after.** Any 2xx with an empty body ends the delivery
   attempt. Do not hold the connection open for downstream work.
3. **Return 5xx only when a retry would help.** A malformed payload is `400`
   and will never succeed; retrying it wastes both sides' time.
4. **Never derive state from arrival order.** Compare `quote_status` against
   your current state and apply the reachability rule.
5. **Alert on conflicting events rather than dropping them.** A conflicting
   terminal means the two companies disagree about money.
6. **Keep a recovery path.** If an event is lost,
   [`GET /v1/quotes/{tesser_quote_id}`](/api-nexa/quotes#get-quote-optional-op-8)
   <N /> and
   [`GET /v1/payments/{nexa_payment_id}`](/api-nexa/payments#get-payment-optional-op-9)
   <N /> answer "what state do you think it's in?" in one call.
