> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a failed-payment recovery ladder

> Turn a payment.failed event from your billing system into a journey that escalates from transactional email on day 0 to SMS on day 3 to a voice call on day 7, with per-step quiet hours, a cost preview, and opt-out rules that keep account-critical notices reachable.

# Build a failed-payment recovery ladder

A failed payment is the canonical multi-channel automation: the customer owes you money, recovery odds fall with every day of silence, and each channel you add costs more than the one before. This tutorial builds a three-step recovery ladder in Devotel Orbit: transactional email the moment the charge fails, SMS on day 3, and a voice call on day 7, with the run ending as soon as the invoice is paid.

You will emit the event from your billing system, enroll it into an event-driven journey, configure the channel ladder and its copy, decide what an opt-out on one leg does to the legs that follow, attach a conversion goal so recovered revenue is attributed to the step that closed it, and drill the failure paths in the sandbox.

The ladder reuses the same primitives as [Build an abandoned-cart recovery flow](https://docs.orbit.devotel.io/guides/abandoned-cart-recovery-flow.md): flat flow variables, delay and condition nodes, and channel steps. The differences are the trigger (a billing event instead of a cart event), an escalating ladder instead of a single fallback, and the opt-out semantics. A payment reminder is account-critical, so a marketing opt-out must not silence it, and the tutorial shows you how to configure that posture deliberately.

## 1. Emit the event from your billing system

Your billing system (Stripe, Chargebee, Braintree, or your own invoicing service) is the source of truth for `payment.failed` and `payment.succeeded`. Post each failure to a webhook-triggered flow as soon as the charge fails:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/flows/flow_dunning/webhook" \
  -H "X-Flow-Signature: $FLOW_SIGNATURE" \
  -H "Idempotency-Key: pf_INV-2041_1" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "payment.failed",
    "contact_id": "contact_02a3f1c9d4e5",
    "invoice_id": "INV-2041",
    "first_name": "Ada",
    "email": "ada@example.com",
    "phone": "+14155550134",
    "amount_due": "86.00",
    "currency": "USD",
    "payment_url": "https://billing.example/pay/INV-2041"
  }'
```

Treat a `202` as an accepted trigger, not as proof that a message was sent. Sign the webhook according to [Webhook security](https://docs.orbit.devotel.io/webhooks/security.md).

Two rules keep enrollment clean:

* **Make retries replay, not re-enroll.** When your queue worker retries the POST after a timeout, send an `Idempotency-Key` derived from the invoice and the failure count, as above. A replayed key returns the original response instead of starting a second ladder for the same invoice. The header contract, TTL windows, and failure modes are in [Idempotent requests](https://docs.orbit.devotel.io/guides/idempotent-requests.md).
* **Emit the success event with the same key.** When the retry succeeds, post `payment.succeeded` with the same `invoice_id`. Section 2 uses that event to end waiting runs.

Map your provider's raw field names (`invoice.id`, `charge.amount_remaining`, `customer.email`) to the flat keys above at the edge. [Event field mappings for integrations](https://docs.orbit.devotel.io/guides/integrations-event-field-mappings.md) covers the mapping table for common billing providers. Keep the variables flat: `{{first_name}}` resolves in flow messages; dotted placeholders such as `{{contact.first_name}}` render blank.

## 2. Enroll on the event, exit on payment

A scheduled drip is the wrong intake for dunning. A drip sends on a calendar whether or not the invoice is open, so it either re-duns a customer who paid this morning or skips one who failed on an off-day. Event-driven enrollment ties one journey run to one invoice: the run starts when `payment.failed` arrives, carries the invoice key for its whole life, and ends the moment `payment.succeeded` arrives for that key. The enrollment and exit semantics are the same ones [Build a campaign journey](https://docs.orbit.devotel.io/guides/campaign-journey-builder.md) defines for event triggers, and [Segment-triggered journeys](https://docs.orbit.devotel.io/guides/segment-triggered-journeys.md) shows the scheduled-intake contrast.

Create the draft flow:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/flows" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Failed-payment recovery ladder",
    "trigger_type": "event",
    "definition": {
      "trigger": { "type": "event", "event": "payment.failed" },
      "nodes": [
        {
          "id": "normalize_invoice",
          "type": "transform",
          "data": {
            "assignments": [
              { "target": "invoice_key", "template": "{{invoice_id}}" },
              { "target": "display_name", "template": "{{first_name}}" },
              { "target": "pay_link", "template": "{{payment_url}}" },
              { "target": "amount_label", "template": "{{amount_due}} {{currency}}" }
            ]
          }
        },
        {
          "id": "attempt_email",
          "type": "sendEmail",
          "data": {
            "to": "{{email}}",
            "subject": "Payment failed for invoice {{invoice_key}}",
            "body": "Hi {{display_name}}, your payment of {{amount_label}} did not go through. Update your payment method to keep your account current: {{pay_link}}"
          }
        },
        { "id": "wait_day_3", "type": "delay", "data": { "amount": 3, "unit": "days" } },
        {
          "id": "still_open_day_3",
          "type": "condition",
          "data": { "field": "payment_open", "operator": "equals", "value": "true" }
        },
        {
          "id": "attempt_sms",
          "type": "sendSms",
          "data": {
            "to": "{{phone}}",
            "body": "Example Co: your invoice {{invoice_key}} for {{amount_label}} is past due. Pay here: {{pay_link}}"
          }
        },
        { "id": "wait_day_7", "type": "delay", "data": { "amount": 4, "unit": "days" } },
        {
          "id": "still_open_day_7",
          "type": "condition",
          "data": { "field": "payment_open", "operator": "equals", "value": "true" }
        },
        {
          "id": "attempt_call",
          "type": "makeCall",
          "data": { "label": "Day-7 recovery call", "to": "{{phone}}" }
        },
        {
          "id": "missed_call_sms",
          "type": "sendSms",
          "data": {
            "to": "{{phone}}",
            "body": "Example Co: we tried to reach you about invoice {{invoice_key}} ({{amount_label}}). Pay here: {{pay_link}}"
          }
        }
      ],
      "edges": [
        { "source": "normalize_invoice", "target": "attempt_email" },
        { "source": "attempt_email", "target": "wait_day_3" },
        { "source": "wait_day_3", "target": "still_open_day_3" },
        { "source": "still_open_day_3", "sourceHandle": "yes", "target": "attempt_sms" },
        { "source": "attempt_sms", "target": "wait_day_7" },
        { "source": "wait_day_7", "target": "still_open_day_7" },
        { "source": "still_open_day_7", "sourceHandle": "yes", "target": "attempt_call" },
        { "source": "attempt_call", "sourceHandle": "error", "target": "missed_call_sms" }
      ]
    }
  }'
```

The `no` edge of each condition is deliberately unwired: when `payment_open` is no longer `"true"`, the run ends without sending. Keep `payment_open` current by mapping the `payment.succeeded` event to the same `invoice_id`, the same way the abandoned-cart guide keeps `cart_purchased` current. If your billing system cannot update a waiting run, use the success event as a second trigger that cancels the matching run, scoped to `invoice_id` only. Never cancel every run for a contact: a customer can have a new open invoice while an older one is being recovered.

Publish only after both branches are tested:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/flows/flow_dunning/publish" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

Send one test event with `payment_open` unset and confirm the run parks as `waiting` after the email leg; send a matching `payment.succeeded` and confirm the day-3 condition takes the unwired `no` path. Runs are accepted asynchronously (`202`) and audited per step with `GET /api/v1/flows/executions/:id`.

## 3. The ladder by attempt: windows and cost

Each attempt has its own channel, its own delay, and its own sending window:

| Attempt | Day | Channel | Window control | Cost driver |
| - | - | - | - | - |
| 1 | 0 | Email, transactional stream | Tenant quiet hours for email | Stream throttle; marginal cost near zero |
| 2 | 3 | SMS | Tenant quiet hours for messaging | Per-segment rate of your SMS route |
| 3 | 7 | Voice | Federal 8 AM to 9 PM recipient-local, enforced by the platform | Per-minute rate of your voice route |

Preview the ladder's cost before you publish. The journey simulator is read-only: it never enrolls a contact and never sends. Give it your own per-send rates and a realistic entry volume:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/journeys/simulate" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "nodes": [ ... ],
    "edges": [ ... ],
    "entryVolume": 500,
    "assumptions": {
      "conditionPassRate": 0.7,
      "costPerSendUsd": { "email": 0.0002, "sms": 0.0079, "voice": 0.021 }
    }
  }'
```

`conditionPassRate` is the share of runs still unpaid at each check; with 500 failed payments and 0.7, the projection shows roughly 350 SMS sends on day 3 and 245 calls on day 7, each with its spend. If the projected voice volume surprises you, widen the day-3-to-day-7 gap or raise the SMS copy's payment-link visibility before you spend voice minutes.

The ladder above escalates on time, not on delivery failure. If you also want a channel fallback inside one attempt (for example, WhatsApp when SMS reaches a terminal delivery failure), configure `cascade_policy` on the send step as described in [Smart-send fallback chains](https://docs.orbit.devotel.io/guides/smart-send-fallback-chains.md). A fallback is a substitute for the same attempt, not a fourth attempt: it inherits the attempt's consent and suppression checks.

## 4. Copy discipline per channel

Dunning copy fails when it reads like marketing. A customer who just had a card decline should get facts and a way to fix it, not a win-back offer.

* **Email (day 0):** account-service tone. Name the invoice, the amount, and the fix. No cross-sell, no banner, no "we miss you" closer. Send it through your transactional stream so a marketing blast can never consume its throttle or drag its sender reputation: provision and pin the stream per [Isolate transactional and marketing email streams](https://docs.orbit.devotel.io/guides/email-streams-isolate-transactional-and-marketing.md).
* **SMS (day 3):** one fact and one link. Identify your business, state the invoice and amount, and include the payment URL. Keep it to a single segment where possible; the amount label and short link are the two fields that push length.
* **Voice (day 7):** a 20-to-30-second script that identifies your business and the purpose of the call, offers the payment URL by SMS or email, and stops there. Voice is the most intrusive leg; use it for the final attempt, not the first.

Per-step windows:

* Email and SMS honor your tenant's quiet hours. If a delay ends inside quiet hours, let the tenant send control defer the message to the next permitted window rather than adding an ad hoc scheduler; see [Quiet hours configuration](https://docs.orbit.devotel.io/guides/quiet-hours-configuration.md) and [Per-campaign quiet hours](https://docs.orbit.devotel.io/guides/per-campaign-quiet-hours.md).
* Voice is additionally bounded by the federal TCPA window, 8 AM to 9 PM in the recipient's local time. The platform enforces this window on campaign and dialer voice regardless of tenant settings, and a call whose recipient timezone cannot be resolved does not go out. [TCPA quiet hours and windows](https://docs.orbit.devotel.io/guides/tcpa-quiet-hours-and-windows.md) has the full reference.

## 5. Opt-out nuance: marketing vs account-critical

Dunning lives in the account-service message class, not the marketing class. The two classes are suppressed differently, and the difference is the whole point of this section.

**A marketing unsubscribe is not an account opt-out.** A contact who left your promotional newsletter still owes you for the current invoice, and the transactional email leg above must still reach them. Keep the classes separate on every channel: separate email streams on the email side, and a [preference center](https://docs.orbit.devotel.io/guides/preference-center-opt-out-page.md) that lets a recipient drop marketing while account notices continue.

**What a STOP on the SMS leg does to later steps.** Keyword handling is per channel. When the contact texts STOP, the consent flip applies to SMS: the send-time gate blocks any later SMS send to that contact, including `missed_call_sms`. It does not, by itself, stop the day-7 voice call, and it does not touch the email leg.

| Leg | After a STOP on the SMS leg |
| - | - |
| Later SMS sends | Blocked at the send-time gate by the flipped SMS consent |
| Day-7 voice call | Still eligible; voice consent is a separate record |
| Transactional email | Unaffected; account-service class on the transactional stream |

**Choose your suppression posture deliberately.** Both postures below are tenant-owned controls; Orbit enforces whichever you configure.

* *Continue account-critical legs (the default shown here):* honor the per-channel consent flip, keep the voice leg and the transactional email leg, and let [opt-out rules](https://docs.orbit.devotel.io/guides/opt-out-rules.md) handle the keyword side. This is the right posture when your terms require notice of past-due balances.
* *Halt the whole ladder on any opt-out:* if your policy treats any STOP as "do not contact me further", write a suppression record for the remaining channels through the [Opt-Outs API](https://docs.orbit.devotel.io/api-reference/optouts.md), or attach a [custom opt-out list](https://docs.orbit.devotel.io/guides/opt-out-lists.md) that covers the senders this ladder uses. Every later step then fails its send-time check and the run winds down without sends.

Either way, [message suppression](https://docs.orbit.devotel.io/guides/message-suppression.md) still guards against the mechanical failure mode: two identical bodies to the same contact on the same channel inside the suppression window collapse to one send.

## 6. Conversion goal and attribution

Mark "paid" as the goal so the ladder reports revenue, not just sends. When you run the ladder as a campaign journey, upsert the goal once per campaign:

```bash theme={null}
curl -X PUT "https://api.orbit.devotel.io/api/v1/campaigns/cmp_dunning/journey/goal" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "goal_event_type": "payment.succeeded",
    "conversion_window_hours": 336,
    "description": "Invoice paid within 14 days of the failed-payment event"
  }'
```

Upsert the goal before you wire conversion recording; a conversion posted to a campaign without a goal returns 404. Record each paid invoice from your billing listener, with the recovered amount in `value_cents`:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/cmp_dunning/journey/goal/conversion" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "contact_id": "contact_02a3f1c9d4e5", "last_node_id": "attempt_sms", "value_cents": 8600 }'
```

Then read the rollup:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/campaigns/cmp_dunning/journey/goal/analytics" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

The response returns `total_enrolled`, `converted`, `conversion_rate`, `total_value_cents`, and a `per_step` breakdown sorted by contribution. `total_value_cents` is your recovered revenue; `per_step` tells you which leg closed the most invoices, attributed to the last journey node the contact saw. Use the per-node counters at `GET /api/v1/campaigns/:id/journey/node-analytics` to find the step that bleeds contacts before they convert, and [Reading journey paths](https://docs.orbit.devotel.io/guides/journey-paths-reading.md) when you want the path-level view.

## 7. Failure drill in the sandbox

Run these three drills before the ladder touches production traffic.

**Drill A: hard bounce on the email leg.** Enroll a test contact with a mailbox that hard-bounces. Fetch the execution trace and confirm the email step ends in a terminal failure while the run still parks at the day-3 delay: the ladder escalates on time, so a bounce does not cancel later legs. Watch the bounce lifecycle webhook write the address to your email suppression list, per [Email suppression event payloads](https://docs.orbit.devotel.io/guides/email-suppression-event-payloads.md); a second run for the same contact must not re-send to the bounced address.

**Drill B: STOP between day 3 and day 7.** Enroll a contact, let the SMS leg send, then text STOP from the test handset. Confirm the consent flip in the contact's consent history and that `missed_call_sms` would now fail its send-time check. Then apply whichever posture you chose in section 5: under the default posture the day-7 call still goes out inside the federal window; under the halt posture the suppression record stops it. Verify the outcome you configured, not the one you intended.

**Drill C: duplicate send inside the suppression window.** Fire the same SMS body to the same contact twice within your suppression window and confirm the second send is skipped as a duplicate in the execution trace. This is the retry-safety net that lets your billing worker retry `payment.failed` deliveries without double-texting a customer.

For all three, audit step by step:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/flows/executions/exec_dunning_123" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

## Troubleshooting

| Symptom | Check |
| - | - |
| The ladder never starts | The event name matches `payment.failed` exactly, the contact has `email` and `phone`, and the webhook delivery returned `202`. |
| A paid customer still gets dunned | Compare `invoice_id` on `payment.failed` and `payment.succeeded`; a contact-only condition can match the wrong invoice. |
| The day-7 call never dials | The recipient's local time is inside 8 AM to 9 PM and the timezone resolves; the platform fails closed on both. |
| SMS leg skipped after a test STOP | Expected: the flipped SMS consent blocks later SMS sends. Check the posture you configured in section 5 for the other channels. |
| Duplicate reminders for one invoice | Your billing worker retried without an `Idempotency-Key`; key retries by invoice and failure count. |

## Next steps

* [Build an abandoned-cart recovery flow](https://docs.orbit.devotel.io/guides/abandoned-cart-recovery-flow.md): the same graph pattern for commerce events.
* [Build a campaign journey](https://docs.orbit.devotel.io/guides/campaign-journey-builder.md): simulator, goals, and launch semantics in depth.
* [Isolate transactional and marketing email streams](https://docs.orbit.devotel.io/guides/email-streams-isolate-transactional-and-marketing.md): keep account-critical email on its own reputation.
* [Opt-out rules](https://docs.orbit.devotel.io/guides/opt-out-rules.md) and [TCPA quiet hours and windows](https://docs.orbit.devotel.io/guides/tcpa-quiet-hours-and-windows.md): the tenant-owned controls this ladder depends on.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.