Skip to content

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 created

The 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:

FieldTypeDescription
dispatched_emailstring | nullThe email address the consolidated voucher email was sent to
email_sent_atISO 8601 | nullTimestamp of the last successful dispatch
voucher_codesstring[]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.

  1. Find the order in the admin panel or via the API:

    GET /admin/orders/:id
    Authorization: Bearer $ADMIN_TOKEN

    Confirm status = NEEDS_ATTENTION and note the unfulfilled order_item_id and its quantity.

  2. Submit the code(s):

    Single-quantity item — use the shorthand code field:

    POST /admin/orders/:id/fulfillment/manual-code
    Authorization: Bearer $ADMIN_TOKEN
    Content-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 codes array (one entry per unit purchased):

    POST /admin/orders/:id/fulfillment/manual-code
    Authorization: Bearer $ADMIN_TOKEN
    Content-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"
    }
  3. Check the response:

    {
    "success": true,
    "message": "3 voucher code(s) delivered to customer",
    "data": {
    "attemptId": 42,
    "correlationId": "a1b2c3d4-...",
    "status": "DISPATCHED",
    "emailSent": true
    }
    }

    A DISPATCHED status 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.

  4. If emailSent: false (email server / SMTP issue):

    • All voucher rows are already saved and linked to the order item.
    • The attempt status is DISPATCH_FAILED and error_message captures the underlying email-pipeline error.
    • order_items.fulfilled stays false and fulfillment_status is set to DISPATCH_FAILED so the failure is visible on the order list / detail without opening the attempts log.
    • The order stays on NEEDS_ATTENTION and 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-codes
Authorization: Bearer $ADMIN_TOKEN
Content-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.length must exactly match that orderItem quantity.
  • codes[].partnerId is optional and overrides item-level partnerId.
  • 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-email
Authorization: Bearer $ADMIN_TOKEN
Content-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.

  1. Trigger a retry:

    POST /admin/orders/:id/fulfillment/partner-retry
    Authorization: Bearer $ADMIN_TOKEN
    Content-Type: application/json
    {
    "orderItemId": 123,
    "notes": "Retrying after Zendit outage resolved at 14:30 UTC"
    }
  2. Optionally force a specific partner source (override the default priority selection):

    {
    "orderItemId": 123,
    "partnerSourceId": 7,
    "notes": "Forcing source #7 which has stock"
    }
  3. 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.

  4. 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-attempts
Authorization: Bearer $ADMIN_TOKEN

Example 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_notes when the alert is auto-resolved on success

Attempt statuses

statusMeaning
DISPATCHEDAll voucher(s) saved and customer email sent. Order is fully recovered.
DISPATCH_FAILEDVoucher(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_FAILEDPartner call completed but returned no code. See partner_response for details.
FAILEDUnexpected error during the operation. See error_message.
SUCCESSUsed by AUTO and RESEND_EMAIL types — operation completed without dispatch semantics.

Attempt types

typeWhen it’s emitted
AUTOOriginal 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_INPUTAdmin pasted code(s) via manual-code / manual-codes.
PARTNER_RETRYAdmin re-ran a partner via partner-retry.
RESEND_EMAILAdmin re-sent the consolidated voucher email via resend-email.

Validation rules

The endpoints enforce these guards before making any external call:

Condition checkedError if violated
Order must have at least one SUCCEEDED payment400 BAD_REQUEST — “refusing to dispatch voucher”
At least one code must be provided (manual-code only)400 BAD_REQUEST
Item must not be fulfilled = true409 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

  1. order_items.fulfilledtrue, fulfilled_at set, fulfillment_statusSUCCEEDED.
  2. orders.dispatched_email and orders.email_sent_at are recorded.
  3. If all items on the order are now fulfilled → orders.statusSUCCEEDED.
  4. Open fulfillment_failed / fulfillment_partial alerts for the order are auto-resolved with a reference to the correlation_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.):

  1. All voucher rows exist and are linked via order_items.voucher_id — nothing is lost.
  2. order_items.fulfilled stays false and fulfillment_status is set to DISPATCH_FAILED so the order list / detail endpoints flag the failure without the operator having to open the attempts log.
  3. orders.status stays NEEDS_ATTENTION; orders.dispatched_email and orders.email_sent_at remain null.
  4. Open admin alerts for the order are not auto-resolved.
  5. Calling POST /admin/orders/:id/fulfillment/manual-code again with the same code(s) is safe and will re-attempt the email.