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.
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.
Step 5 · Create the payment [N]
Operation: POST /v1/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
201means the payout was executed — normallypayment_status: "COMPLETED"andquote_status: "PAYMENT_COMPLETED", withcompleted_atand thenexa_payment_idfor reconciliation. Tesser does not poll and does not wait for a confirmation event. - Precondition: the quote must be
CREDIT_CONFIRMED. Earlier than that is409 QUOTE_NOT_CREDITED; a rejected quote is409 QUOTE_REJECTED. - One payment per quote in the MVP. A retry returns
201with the current payment object; a differentgtq_amountwhen a payment already exists is409 PAYMENT_CONFLICT. - A declined payout also comes back on this call. Because the money
moves inside the request, Nexa answers
201withpayment_status: "FAILED"andquote_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 aCOMPLETEDresponse. - 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_idtherefore 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 [N] is keyed onnexa_payment_id, which only exists once the response comes back. The quote read [N] is reachable mid-execution but not informative: no in-progress state is representable, so it still reportsCREDIT_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.
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. |
Step 6 · Failure after a COMPLETED response [T]
Operation: POST /v1/webhooks/nexa/{organizationId}: 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).
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.
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. |
Recovering state
If the failure webhook is missed, the optional
GET /v1/payments/{nexa_payment_id}
[N] returns the current payment_status and status_metadata, and
GET /v1/quotes/{tesser_quote_id}
[N] returns the merged quote including its payment sub-object.
Next: 5 · Treasury.