# 4 · GTQ payout

Phase 4 of the flow. The GTQ is in Felix's Nexa account; Tesser now instructs
Nexa to pay it out.

**The payout is synchronous.** Nexa performs it and returns the result in the
response to the same call. There is no pending state to poll and no progress
webhook to wait for. A webhook arrives in exactly one case: a failure that
surfaces on the rail after a `COMPLETED` response.

<Callout type="note" title="Scope of this integration: one fixed beneficiary">

In this phase Nexa always pays **the same beneficiary**, configured once
out-of-band and never present in an API payload. That beneficiary is the
**payout partner**, which performs the last-mile payout to the individual
remittance receivers.

**Nexa is not involved in the last mile.** Every payout goes to
the same place, so there are no per-recipient beneficiary details, no
destination objects and no beneficiary compliance fields in this contract.
Nexa taking on last-mile remittance payouts is a later stage of the
programme and out of scope here. See [scope of the MVP](/flow-nexa/lifecycle#scope-of-the-mvp).

</Callout>

<Mermaid
  chart={`sequenceDiagram
    autonumber
    participant T as Tesser (orchestrator)
    participant N as Banco Nexa

    Note over T,N: Phase 4 · GTQ payout to the fixed beneficiary
    T->>N: POST /v1/payments (gtq_amount cross-check) [N]
    N-->>T: 201 with the outcome: payment COMPLETED, or FAILED on a synchronous decline
    opt Failure surfacing after a COMPLETED response
        N->>T: webhook payment.status.changed (FAILED / RETURNED) [T]
    end`}
/>

## Step 5 · Create the payment <N />

**Operation:** [`POST /v1/payments`: Create payment (op 5)](/api-nexa/payments#create-payment-op-5)
· hosted by **Nexa**, called by **Tesser**.

The request carries `tesser_quote_id` and `gtq_amount`. The amount is
**deliberately redundant**: Nexa validates that it equals the credited amount
from its own `quote.credit.confirmed` event and returns `422 AMOUNT_MISMATCH`
otherwise. An explicit amount in a money-moving instruction is cheap
insurance against wrong-quote-ID bugs.

- **The response is the outcome.** A `201` means the payout was executed —
  normally `payment_status: "COMPLETED"` and
  `quote_status: "PAYMENT_COMPLETED"`, with `completed_at` and the
  `nexa_payment_id` for reconciliation. Tesser does not poll and does not
  wait for a confirmation event.
- **Precondition:** the quote must be `CREDIT_CONFIRMED`. Earlier than that
  is `409 QUOTE_NOT_CREDITED`; a rejected quote is `409 QUOTE_REJECTED`.
- **One payment per quote** in the MVP. A retry returns `201` with the
  current payment object; a different `gtq_amount` when a payment already
  exists is `409 PAYMENT_CONFLICT`.
- **A declined payout also comes back on this call.** Because the money
  moves inside the request, Nexa answers `201` with
  `payment_status: "FAILED"` and `quote_status: "PAYMENT_FAILED"` when it
  declines or cannot execute the payout during the call. A failure is only a
  webhook when it surfaces on the rail *after* a `COMPLETED` response.
- **A timed-out call is retried, and the retry joins the execution already
  running.** Because the payout happens inside the call, a network timeout
  leaves Tesser without an outcome while Nexa may still be executing. A retry
  carrying the same `tesser_quote_id` therefore **joins that in-flight
  execution and returns its final outcome**: it never starts a second payout,
  and it never fails merely because one is already running. There is no
  *payment* read during execution, because
  [get payment](/api-nexa/payments#get-payment-optional-op-9) <N /> is keyed
  on `nexa_payment_id`, which only exists once the response comes back. The
  [quote read](/api-nexa/quotes#get-quote-optional-op-8) <N /> is reachable
  mid-execution but not informative: no in-progress state is representable,
  so it still reports `CREDIT_CONFIRMED`. If the retry times out too, the
  recovery is another retry, or the quote read once an outcome exists. Whether Nexa can implement the join is
  [open question 5](/open-questions).

<div className="what-can-go-wrong">

**What can go wrong: create payment**

| `error_code` | HTTP | When |
| --- | --- | --- |
| `VALIDATION_ERROR` | 400 | Malformed amount or bad UUID. |
| `UNAUTHENTICATED` | 401 | Client certificate or customer API key missing/invalid. |
| `QUOTE_NOT_FOUND` | 404 | No quote with this `tesser_quote_id`. |
| `QUOTE_NOT_CREDITED` | 409 | Credit is not yet `CREDIT_CONFIRMED`. |
| `QUOTE_REJECTED` | 409 | The quote is in `REJECTED`. |
| `PAYMENT_CONFLICT` | 409 | The quote already has a different payment. |
| `AMOUNT_MISMATCH` | 422 | `gtq_amount` does not equal the credited amount. |
| `INTERNAL_ERROR` | 500 | Unexpected failure on Nexa's side. |

</div>

## Step 6 · Failure after a `COMPLETED` response <T />

**Operation:** [`POST /v1/webhooks/nexa/{organizationId}`: Receive Nexa webhook (op 4)](/api-tesser/webhooks#receive-nexa-webhook-op-4)
· hosted by **Tesser**, called by **Nexa**.

This is the **only** webhook in the payout leg, and Nexa sends it **only when
something goes wrong after a successful response**: the payout executed and
reported `COMPLETED`, then failed or was returned on the rail. A decline
during the call itself never becomes a webhook; it comes back synchronously
on create payment.

| `event_type` | Meaning | `data` |
| --- | --- | --- |
| `payment.status.changed` | The payout failed or was returned after the fact. | `tesser_quote_id`, `nexa_payment_id`, `payment_status` (`FAILED` or `RETURNED`), `quote_status` (`PAYMENT_FAILED` or `PAYMENT_RETURNED`), `status_metadata` (free-form object: rail references, failure reasons) |

There is **no success webhook**. A quote that reaches `PAYMENT_COMPLETED` and
stays there is simply done; silence means success.

`status_metadata` is the one place in the contract where free-form content is
expected, because rail references and failure reasons genuinely vary.

Recovery from `PAYMENT_FAILED` and `PAYMENT_RETURNED` is **manual and
out-of-band** in the POC. There are no API retry semantics
([open question 7](/open-questions)).

Which Guatemalan rail(s) are used, whether `RETURNED` is real on that rail,
how long after a `COMPLETED` response a failure can still surface, and the
contents of `status_metadata` are [open question 5](/open-questions).

<div className="what-can-go-wrong">

**What can go wrong: failure notification**

| Situation | Behaviour |
| --- | --- |
| `VALIDATION_ERROR` 400 | Malformed envelope or `data` for the event type. |
| `UNAUTHENTICATED` 401 | mTLS identity missing, unrecognised, or not matching `{organizationId}`. |
| `INTERNAL_ERROR` 500 | Tesser-side failure. Nexa retries. |
| Duplicate delivery | Deduped on `event_id`; still acked 2xx. |
| A failure event for a quote already in `PAYMENT_FAILED` | Deduped or treated as a no-op; the state does not move twice. |

</div>

## Recovering state

If the failure webhook is missed, the optional
[`GET /v1/payments/{nexa_payment_id}`](/api-nexa/payments#get-payment-optional-op-9)
<N /> returns the current `payment_status` and `status_metadata`, and
[`GET /v1/quotes/{tesser_quote_id}`](/api-nexa/quotes#get-quote-optional-op-8)
<N /> returns the merged quote including its `payment` sub-object.

**Next:** [5 · Treasury](/flow-nexa/treasury).
