Fulfillment Recovery
When a customer’s payment succeeds but the voucher could not be delivered (partner
API down, no stock, network timeout) the order lands in NEEDS_ATTENTION status
and an alert is raised in the admin panel.
This guide explains the two recovery paths available to operators and the audit trail they produce.
When does this happen?
Payment SUCCEEDED → FulfillmentService → partner call FAILS ↓ order.status = NEEDS_ATTENTION admin_alerts row createdThe customer was charged but has no voucher. The order must never be marked
FAILED from the customer’s perspective — they paid. NEEDS_ATTENTION is the
internal signal that operator action is required.
How email delivery works (consolidated email)
Every order — whether it has one item or many, and regardless of quantity — sends exactly one email to the customer. All voucher codes from every item and every quantity unit are bundled into a single “Your vouchers — Order #X” message.
The orders response now includes two fields you can use to verify delivery:
| Field | Type | Description |
|---|---|---|
dispatched_email | string | null | The email address the consolidated voucher email was sent to |
email_sent_at | ISO 8601 | null | Timestamp of the last successful dispatch |
voucher_codes | string[] | Flat list of all codes across every item (same list the customer received) |
Example order response snippet:
{ "status": "SUCCEEDED", "dispatched_email": "customer@example.com", "email_sent_at": "2026-04-27T14:22:00.000Z", "voucher_codes": ["ABCD-1234", "EFGH-5678", "IJKL-9012"], "items": [ { "variant_id": 42, "quantity": 3, "voucher_codes": ["ABCD-1234", "EFGH-5678", "IJKL-9012"] } ]}Recovery path 1 — Enter codes manually
Use this when you have voucher codes from the partner (e.g. a supplier email or your own inventory) and want to deliver them to the customer immediately.
-
Find the order in the admin panel or via the API:
GET /admin/orders/:idAuthorization: Bearer $ADMIN_TOKENConfirm
status = NEEDS_ATTENTIONand note the unfulfilledorder_item_idand itsquantity. -
Submit the code(s):
Single-quantity item — use the shorthand
codefield:POST /admin/orders/:id/fulfillment/manual-codeAuthorization: Bearer $ADMIN_TOKENContent-Type: application/json{"orderItemId": 123,"code": "ABCD-1234-EFGH-5678","pin": "9999","redemptionUrl": "https://partner.example/redeem","partnerId": 5,"notes": "Manually sourced from supplier email 2026-04-27"}Multi-quantity item — use the
codesarray (one entry per unit purchased):POST /admin/orders/:id/fulfillment/manual-codeAuthorization: Bearer $ADMIN_TOKENContent-Type: application/json{"orderItemId": 123,"codes": [{"code": "ABCD-1234-EFGH-5678","pin": "1111","expiresAt": "2027-12-31T23:59:59.000Z","metadata": { "supplier_batch": "BATCH-2026-04" }},{ "code": "WXYZ-9876-MNOP-5432", "pin": "2222" },{ "code": "QRST-1122-UVWX-3344", "pin": "3333" }],"partnerId": 5,"notes": "3× quantity — all codes sourced from supplier"} -
Check the response:
{"success": true,"message": "3 voucher code(s) delivered to customer","data": {"attemptId": 42,"correlationId": "a1b2c3d4-...","status": "DISPATCHED","emailSent": true}}A
DISPATCHEDstatus means all voucher rows were saved and the customer received a single consolidated email containing every code, identical in format to the automatic fulfillment email. -
If
emailSent: false(email server / SMTP issue):- All voucher rows are already saved and linked to the order item.
- The attempt status is
DISPATCH_FAILEDanderror_messagecaptures the underlying email-pipeline error. order_items.fulfilledstaysfalseandfulfillment_statusis set toDISPATCH_FAILEDso the failure is visible on the order list / detail without opening the attempts log.- The order stays on
NEEDS_ATTENTIONand related alerts are NOT auto-resolved. - Submitting the same code(s) again is safe — the idempotency guard only
blocks once an attempt reaches
DISPATCHED.
Fulfill the whole order in one request
When an order has multiple products/items, you can submit all item codes in one
API call instead of calling manual-code repeatedly:
POST /admin/orders/:id/fulfillment/manual-codesAuthorization: Bearer $ADMIN_TOKENContent-Type: application/json
{ "notes": "Recovered after provider outage", "items": [ { "orderItemId": 123, "variantId": 22402, "codes": [ { "code": "ABCD-1234", "partnerId": 5 }, { "code": "EFGH-5678", "partnerId": 8 } ] }, { "orderItemId": 124, "partnerId": 5, "codes": [ { "code": "IJKL-9012", "pin": "9999" } ] } ]}items[].codes.lengthmust exactly match thatorderItemquantity.codes[].partnerIdis optional and overrides item-levelpartnerId.- Mixed providers are supported in one order (and even within one item).
- Response returns per-item results so failed rows can be retried selectively.
All successful admin recovery paths now use the same consolidated voucher email as the regular customer checkout flow. That means:
- one email per order (not one email per item),
- every code currently available on the order is included,
- each code is labeled with its product and variant so customers can distinguish them easily.
Re-send all order codes from admin
If vouchers are already stored on the order and the operator just wants to send the email again, use:
POST /admin/orders/:id/fulfillment/resend-emailAuthorization: Bearer $ADMIN_TOKENContent-Type: application/json
{ "notes": "Customer requested resend"}This sends one consolidated email containing all persisted codes on the order with product / variant labels.
Recovery path 2 — Retry with the partner
Use this when the original failure was transient (partner API blip, rate limit, temporary stock issue) and the partner should be able to fulfill now.
-
Trigger a retry:
POST /admin/orders/:id/fulfillment/partner-retryAuthorization: Bearer $ADMIN_TOKENContent-Type: application/json{"orderItemId": 123,"notes": "Retrying after Zendit outage resolved at 14:30 UTC"} -
Optionally force a specific partner source (override the default priority selection):
{"orderItemId": 123,"partnerSourceId": 7,"notes": "Forcing source #7 which has stock"} -
On success the response mirrors the manual-code path:
{"success": true,"message": "Voucher delivered via zendit","data": {"status": "DISPATCHED","attemptId": 43,"emailSent": true}}The partner delivers however many codes the order requires and the customer receives the same consolidated email as automatic fulfillment.
-
On partner failure the endpoint still returns HTTP 200 with
success: false:{"success": false,"message": "Partner responded with status NO_STOCK","data": {"status": "PARTNER_FAILED","attemptId": 44,"errorMessage": "No available fulfillment source for this product"}}
Viewing the audit trail
Every attempt — regardless of outcome — is logged and queryable:
GET /admin/orders/:id/fulfillment-attemptsAuthorization: Bearer $ADMIN_TOKENExample response entry:
{ "id": 44, "type": "PARTNER_RETRY", "status": "PARTNER_FAILED", "correlation_id": "a1b2c3d4-5e6f-...", "error_message": "No available fulfillment source for this product", "partner_response": { "status": "NO_STOCK", "offerId": "zen_abc123" }, "created_at": "2026-04-27T14:31:00.000Z", "admin_user": { "id": 2, "name": "Ops Team", "email": "ops@bdvoucher.com" }, "partner_api_transaction": { "id": 501, "status": "FAILED", "partner_transaction_id": null, "voucher_code": null, "created_at": "2026-04-27T14:31:00.000Z" }}For manual-code attempts with multiple codes, the code_provided field in
the attempt log shows "FIRST-CODE (+N more)" as a human-readable summary; the
full list is in partner_response.allCodes.
The correlation_id is also echoed in:
- Structured server logs (search by
fa:<correlation_id>) - The
admin_alerts.resolution_noteswhen the alert is auto-resolved on success
Attempt statuses
status | Meaning |
|---|---|
DISPATCHED | All voucher(s) saved and customer email sent. Order is fully recovered. |
DISPATCH_FAILED | Voucher(s) saved but email delivery failed. Order stays on NEEDS_ATTENTION; order_items.fulfillment_status is set to DISPATCH_FAILED. Retry the same endpoint to re-send. |
PARTNER_FAILED | Partner call completed but returned no code. See partner_response for details. |
FAILED | Unexpected error during the operation. See error_message. |
SUCCESS | Used by AUTO and RESEND_EMAIL types — operation completed without dispatch semantics. |
Attempt types
type | When it’s emitted |
|---|---|
AUTO | Original automatic attempt triggered by the payment-success pipeline. Mirrored from the partner call so admins see the original failure alongside any subsequent recovery rows. |
MANUAL_CODE_INPUT | Admin pasted code(s) via manual-code / manual-codes. |
PARTNER_RETRY | Admin re-ran a partner via partner-retry. |
RESEND_EMAIL | Admin re-sent the consolidated voucher email via resend-email. |
Validation rules
The endpoints enforce these guards before making any external call:
| Condition checked | Error if violated |
|---|---|
Order must have at least one SUCCEEDED payment | 400 BAD_REQUEST — “refusing to dispatch voucher” |
| At least one code must be provided (manual-code only) | 400 BAD_REQUEST |
Item must not be fulfilled = true | 409 CONFLICT |
No prior DISPATCHED attempt exists for this item (idempotency) | 409 CONFLICT |
| No code in the request is already assigned to a different item (manual-code only) | 409 CONFLICT |
What happens after successful recovery
order_items.fulfilled→true,fulfilled_atset,fulfillment_status→SUCCEEDED.orders.dispatched_emailandorders.email_sent_atare recorded.- If all items on the order are now fulfilled →
orders.status→SUCCEEDED. - Open
fulfillment_failed/fulfillment_partialalerts for the order are auto-resolved with a reference to thecorrelation_id.
The customer receives the standard voucher delivery email — one email containing every code, identical to the email sent by an automatically fulfilled order.
What happens when email delivery fails
If the voucher(s) are saved but the email pipeline throws (SMTP outage, invalid recipient address, etc.):
- All voucher rows exist and are linked via
order_items.voucher_id— nothing is lost. order_items.fulfilledstaysfalseandfulfillment_statusis set toDISPATCH_FAILEDso the order list / detail endpoints flag the failure without the operator having to open the attempts log.orders.statusstaysNEEDS_ATTENTION;orders.dispatched_emailandorders.email_sent_atremainnull.- Open admin alerts for the order are not auto-resolved.
- Calling
POST /admin/orders/:id/fulfillment/manual-codeagain with the same code(s) is safe and will re-attempt the email.