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

# Signup Recovery

> Call people who reach your paywall and never pay. hyzl checks RevenueCat or Stripe before every call.

Signup Recovery calls people who reach your paywall and do not buy. Your backend posts hyzl one event when someone leaves without buying. hyzl waits, then checks RevenueCat or Stripe for a purchase since. If the person still has not paid, hyzl texts a heads-up and calls with your win-back offer.

<Note>
  Signup Recovery needs a RevenueCat or Stripe account connected, the same one
  used for [cancellation recovery](/docs/revenue-recovery/overview). See
  [Connecting RevenueCat](/docs/revenue-recovery/setup/connecting-revenuecat) or
  [Connecting Stripe](/docs/revenue-recovery/setup/connecting-stripe).
</Note>

## Recommended paywall setup

hyzl checks whether a person paid by asking your billing provider. That check needs one stable id for the person. The standard paywall for each provider already carries one. Start there.

### Stripe

Use a hosted Stripe Checkout Session in subscription mode. Set `client_reference_id` (or `metadata.userId`) to your own user id. Stripe grants access on `checkout.session.completed`, and hyzl reads the same id to match the payment.

The Stripe Pricing Table, Payment Links, and the Payment Element all work too. Set the same id on each one.

### RevenueCat

Use RevenueCat Paywalls in your app. Send your RevenueCat App User ID as `userId`. hyzl renamed this field from `appUserId` to `userId`. It is always present at the paywall, anonymous (`$RCAnonymousID`) or logged in. A custom paywall built on `getOfferings()` works too, and the App User ID is available there as well.

### Do not create billing records before payment

Do not create a Stripe `Customer` before the person pays. Stripe stopped doing this by default in 2022. A `Customer` for every visitor builds up records for people who never paid.

## Identify the person

Every event needs one durable id for the person. hyzl uses it to ask your billing provider whether they paid. Send `userId`, or a Stripe `Customer` id (`stripeCustomerId`). With RevenueCat, `userId` is the only id hyzl can check.

The `userId` is your own stable, internal id for the person. Use your auth system's user id, or any stable id that you generate and never reuse. Do not use an email or a phone number. People change email addresses, and carriers recycle phone numbers. hyzl uses email and phone only to find a person, never as the id itself.

An email or phone number on its own is not a durable id. hyzl rejects an event that carries no durable id, so send one every time.

You can send one id or several. hyzl checks every id it holds and never overwrites an id you already sent.

### Anonymous or skippable paywalls

Some paywalls appear before signup, or let the person skip signup. Use persistent anonymous auth so you still have one stable id.

* RevenueCat: the `$RCAnonymousID` from `configure()`. `Purchases.logIn()` keeps it linked after signup.
* Supabase or Firebase: anonymous sign-in. The anonymous uid survives signup and becomes the account id.

Send that persistent id as the `userId`. It stays the same before and after signup, so hyzl needs no other linking. If you cannot use anonymous auth, generate a server-side id per device and send that. Never send an event with no durable id.

## The request

Your backend posts one event per signup:

```
POST {{YOUR_WEBHOOK_URL}}
Authorization: Bearer {{YOUR_INGEST_TOKEN}}
Content-Type: application/json
```

## Where to place the call

Where you call the webhook decides when we start. The event is the trigger. We wait 5 to 60 minutes, then check whether the person has paid. We call only if they have not. We never call a signup older than 48 hours. So send the event at the buy decision, not before it.

| Where you call it                                         | Fit             | Why                                                                                                                      |
| --------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| The paywall is shown and the person leaves without buying | **Recommended** | They saw your price and did not buy. A real recovery candidate.                                                          |
| The paywall renders                                       | Good            | Clear buy intent. The wait absorbs anyone still deciding.                                                                |
| Signup completes                                          | Too early       | They are still onboarding and have not reached the buy decision. Many pay on their own, so they get an unnecessary call. |
| A phone number is collected, before the paywall           | Too early       | Same as signup completes, when you collect the phone number before they see the price.                                   |

Send the event at the paywall, the moment someone sees your price and does not buy.

## Fields

| Field                                                             | Required?                                                          | What breaks without it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `phoneNumber`                                                     | **Required**, by the final step. It can arrive in a later call.    | Nobody can be contacted. The signup is stored and shown as waiting for a phone number.                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `consent.attested`                                                | **Required**                                                       | The signup is stored but never contacted. Send the real state of your consent checkbox, including `false`.                                                                                                                                                                                                                                                                                                                                                                                                     |
| `consent.text`, `consent.at`                                      | Optional                                                           | The exact consent wording you showed and when they ticked it, stored with the call record as your TCPA evidence trail.                                                                                                                                                                                                                                                                                                                                                                                         |
| `userId`                                                          | **Conditional**, required if you use RevenueCat                    | We cannot check whether this person has paid, so we will not call them. Use the same ID your app passes to `Purchases.logIn()`.                                                                                                                                                                                                                                                                                                                                                                                |
| `stripeCustomerId`                                                | **Conditional**, required for Stripe unless you send `userId`      | Must be a real Stripe `cus_…` customer ID. We cannot check whether this person has paid, so we will not call them.                                                                                                                                                                                                                                                                                                                                                                                             |
| `email`                                                           | Optional                                                           | A lookup attribute we match on to find a Stripe customer. Never a durable identity on its own — an event with only email or phone is rejected.                                                                                                                                                                                                                                                                                                                                                                 |
| `occurredAt`                                                      | Optional                                                           | The wait is measured from when we receive the event instead of when it happened.                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `startTimerAt`                                                    | Optional                                                           | The moment the recovery timer should count from. It beats `occurredAt`. Send it at signup to start the timer there, so you can recover people who never reached the paywall. Send it again on a later event with the same identity to reset the timer to that new moment, for example on abandonment or when they come back. Send it from your backend, not the browser, so your `ingest token` stays off the client.                                                                                          |
| `firstName`                                                       | Optional                                                           | The call uses generic wording. Never used to match people, only to greet them.                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `lastName`, `locale`, `country`, `productId`, `price`, `currency` | Optional                                                           | Small loss of detail in the call script and your reporting.                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `eventId`                                                         | Optional                                                           | Send one to make retries safe. We ignore a repeat of an ID we have already processed.                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `stage`                                                           | Optional                                                           | Send how far they got so the call adapts. Recommended values: `saw_value` (they saw a real result), `saw_price` (they reached the price and did not buy), `pre_offer` (they left before the price). Any custom label you use works too and is passed to the call as context. Absent uses the generic never-paid script.                                                                                                                                                                                        |
| `recoveryUrl`                                                     | Optional. Only when your workflow sends your own recovery links.   | Send your own `https` recovery link and hyzl texts it as the win-back link instead of building one. It can be a web-billing link, a Stripe link, or a deep link to your paywall. It is opaque to hyzl: we never read or change it. Send it on the first event or a later one for the same person, and a later value replaces an earlier one. Rejected with `invalid_recovery_url` if it is not a valid `https` URL.                                                                                            |
| `percentOff`                                                      | Optional. Only when your workflow sends your own win-back percent. | Send your own win-back discount percent, a whole number from 1 to 90. hyzl quotes it on the call and in the offer text, and on a hyzl-built Stripe link, applies it at checkout too. A RevenueCat App Store code keeps the discount set in App Store Connect, so there hyzl quotes the campaign percent that matches the code instead. Send it on the first event or a later one for the same person, and a later value replaces an earlier one. Rejected with `invalid_percent_off` if it is outside 1 to 90. |
| `test`                                                            | Optional                                                           | For trying the flow, not real signups. `true` fires a call within a minute to the number on this event, skipping the wait, the paid check, and your on/off switch. Send a number you control, and never `true` on a real signup — it would call that person without checking they had not already paid. The dashboard "Send a test call to my phone" button does this to your own number.                                                                                                                      |

### The minimum event

The smallest event that produces a call depends on your billing provider:

* **RevenueCat**: `phoneNumber` and `consent.attested: true`, plus `userId`
* **Stripe**: `phoneNumber` and `consent.attested: true`, plus `stripeCustomerId` or `userId`

The identity field is what lets us confirm the person never paid. RevenueCat answers only for an `app_user_id`. Stripe answers for a `Customer` id (a `cus_…`), and a `userId` works for either rail. Email and phone are lookup-only, never a durable identity, so an event carrying only a phone or email is rejected. hyzl stores an event that has a phone number but no identity, never verifies it, and never calls the person.

<Warning>
  `userId` must be the same ID your app passes to RevenueCat's
  `Purchases.logIn()`. If it is anything else, the purchase check silently
  matches nobody. Every signup then reads as unpaid, and hyzl can call someone
  who already paid.
</Warning>

<Warning>
  If you use Stripe, set `client_reference_id` or `metadata.userId` on the
  Checkout Session. Use the same ID you use for this person elsewhere, for
  example the `userId` you send us. Stripe has no equivalent of
  `Purchases.logIn()`. Without one of these, we can only match on
  `stripeCustomerId` or email, and email is the weakest match we have.
</Warning>

<Note>
  The Stripe check looks for subscriptions, including cancelled and expired
  ones. A one-time Stripe payment does not count as paid. A member whose only
  purchase is a one-time product can still get a call.
</Note>

## Get your webhook URL and `ingest token`

1. Go to **Revenue Recovery → Workflows** and click **New workflow**
2. Choose **"A user signs up but does not pay"** as the trigger
3. Connect (or choose) whichever provider your purchases run through: **RevenueCat** or **Stripe**
4. Set your win-back offer, the wait time, the daily call cap, and the calling window
5. Copy your **webhook URL**, your `ingest token`, and the copy-paste snippets in curl, Node, and Swift
6. Tick the consent acknowledgment. Nothing turns on until you do, here or from the Workflows list
7. Finish the campaign (script, voice, schedule) and hit **Finalize & Launch**

Send the URL and `ingest token` to your developer. Or hand them the developer bundle described at the end of this page.

### Your webhook URL is stable

The URL does not change when you edit the workflow, pause it, or relaunch it. You can hardcode it. It changes only if you delete the workflow and create a new one, which retires the old URL.

If your integration keeps posting to a retired URL, we answer `404`. We put your current URL in the response body as `url`. The dashboard also warns you, with a count and the last time it happened. Then you know to move your integration over. Until you do, those signups reach nobody.

## Send the event

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST {{YOUR_WEBHOOK_URL}} \
    -H "Authorization: Bearer {{YOUR_INGEST_TOKEN}}" \
    -H "Content-Type: application/json" \
    -d '{
      "userId": "user_123",
      "phoneNumber": "+13105551212",
      "email": "person@example.com",
      "firstName": "Sam",
      "consent": { "attested": true },
      "eventId": "signup_abc123"
    }'
  ```

  ```js Node theme={null}
  // Call this at the paywall, when the person does not buy. Send it after you have their phone number.
  await fetch("{{YOUR_WEBHOOK_URL}}", {
    method: "POST",
    headers: {
      Authorization: "Bearer {{YOUR_INGEST_TOKEN}}",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      // Must be the same ID you pass to Purchases.logIn()
      userId: user.id,
      phoneNumber: user.phoneE164,
      email: user.email,
      firstName: user.firstName,
      // The real state of your consent checkbox, not a hardcoded true
      consent: { attested: user.contactConsent === true },
      eventId: signup.id,
    }),
  });
  ```

  ```swift Swift theme={null}
  // Call this at the paywall, when the person does not buy. Send it after you have their phone number.
  var request = URLRequest(url: URL(string: "{{YOUR_WEBHOOK_URL}}")!)
  request.httpMethod = "POST"
  request.setValue("Bearer {{YOUR_INGEST_TOKEN}}", forHTTPHeaderField: "Authorization")
  request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  request.httpBody = try JSONSerialization.data(withJSONObject: [
    // Must be the same ID you pass to Purchases.logIn()
    "userId": user.id,
    "phoneNumber": user.phoneE164,
    "email": user.email,
    "firstName": user.firstName,
    // The real state of your consent checkbox, not a hardcoded true
    "consent": ["attested": user.contactConsent],
    "eventId": signup.id,
  ])

  let (_, response) = try await URLSession.shared.data(for: request)
  ```
</CodeGroup>

### On an edge runtime

If you send this from an edge runtime, let the POST finish after you return the response. It then never blocks the user and is never cut off. Pair it with a stable `eventId` so a retry that lands twice is de-duped.

<CodeGroup>
  ```js Cloudflare Workers / Vercel Edge theme={null}
  // ctx.waitUntil keeps the request alive until the POST settles, after the response returns.
  export default {
    async fetch(request, env, ctx) {
      ctx.waitUntil(
        fetch("{{YOUR_WEBHOOK_URL}}", {
          method: "POST",
          headers: {
            Authorization: "Bearer {{YOUR_INGEST_TOKEN}}",
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            userId: user.id,
            phoneNumber: user.phoneE164,
            consent: { attested: user.contactConsent === true },
            eventId: signup.id,
          }),
        }),
      );
      return new Response("ok");
    },
  };
  ```

  ```js Supabase Edge Functions theme={null}
  // EdgeRuntime.waitUntil is the Deno-runtime equivalent of ctx.waitUntil.
  EdgeRuntime.waitUntil(
    fetch("{{YOUR_WEBHOOK_URL}}", {
      method: "POST",
      headers: {
        Authorization: "Bearer {{YOUR_INGEST_TOKEN}}",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        userId: user.id,
        phoneNumber: user.phoneE164,
        consent: { attested: user.contactConsent === true },
        eventId: signup.id,
      }),
    }),
  );
  ```
</CodeGroup>

## Send it in steps

Most apps do not have a phone number at signup. Send what you have, then send the rest once you have it. Use the same `userId` on every step. We assemble the record for you, joining on `userId` first, then email, then phone number, whichever the two requests share.

```js Node theme={null}
// Step 1, when you first know the person. No phone number yet.
await postToHyzl({ userId: user.id, email: user.email });

// Step 2, at the paywall, once you have the phone number and consent. Same userId.
// This step starts the wait, so place it at the paywall, not at signup.
await postToHyzl({
  userId: user.id,
  phoneNumber: user.phoneE164,
  consent: { attested: user.contactConsent === true },
});

async function postToHyzl(payload) {
  await fetch("{{YOUR_WEBHOOK_URL}}", {
    method: "POST",
    headers: {
      Authorization: "Bearer {{YOUR_INGEST_TOKEN}}",
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });
}
```

A later step never erases an earlier field. Sending step 2 without `firstName` does not blank out a `firstName` step 1 already sent.

## Consent

Add a checkbox next to where you already collect the phone number. Here is the wording we provide:

<Frame>
  <label style={{ display: "flex", alignItems: "flex-start", gap: "0.6rem" }}>
    <input type="checkbox" disabled style={{ marginTop: "0.25rem" }} />

    <span>
      I agree that Your company and its service providers may contact me by
      phone call and text message at the number provided, including with
      automated or AI-assisted technology, about my account and related offers.
      Consent is not a condition of purchase. Message and data rates may apply.
      Reply STOP to opt out.
    </span>
  </label>
</Frame>

This checkbox lives in your signup form. We supply the wording. We never render it ourselves.

Replace "Your company" with your own name. Each clause is doing a job:

| Clause                                   | Why it's there                                        |
| ---------------------------------------- | ----------------------------------------------------- |
| "phone call and text message"            | Names both channels you may be contacted on.          |
| "automated or AI-assisted technology"    | Discloses that the call may be placed by an AI agent. |
| "Consent is not a condition of purchase" | Says that declining does not block the signup.        |
| "Reply STOP to opt out"                  | Gives a documented way to withdraw consent.           |

* Put the checkbox wherever you already collect the phone number.
* Keep the phone field optional. The signup must finish without it, because consent cannot be a condition of purchase.
* Leave it unchecked by default. A pre-ticked box is not consent.
* Send us its real state as `consent.attested`, true or false. Do not send true when it is unchecked.
* We store signups that arrive without consent, and we never call them.

If your signup flow has no phone field today, add one at the paywall step. Keep it optional, and the checkbox with it.

The rule to keep the phone field optional assumes a web signup form. If your product uses phone-number login, the phone number is your login, so you need it. Keep the consent checkbox optional instead. Consent still cannot be a condition of signup.

## Wait time and re-enrollment

After we receive a phone number and `consent.attested: true`, we wait before checking whether the person has paid. The default is 5 minutes, adjustable from 5-60 minutes when you set up the workflow.

The wait runs from a single anchor. That is `occurredAt` if you send it, otherwise when we receive the event. Send `startTimerAt` to set the anchor yourself. It beats `occurredAt`. Send it at signup to start the timer there. You can then recover people who never reached the paywall. Send it again on a later event with the same identity to reset the timer. We move the anchor to that new moment. Send `startTimerAt` from your backend, not the browser, so your `ingest token` stays off the client.

The wait is not what protects a paying member from a call. RevenueCat and Stripe both know about a purchase within seconds, and we re-check before we queue the call. The wait mainly absorbs ordinary delivery delays on your side.

There is one gap worth knowing about. When a day's calls hit your daily cap, the remaining calls are held to the next day. The paid check for those ran before they were held. Someone who buys overnight, in that window, can still be called the following morning. Purchases that reach us through RevenueCat or Stripe while a call is still queued do cancel it. So this only affects a purchase we hear about after the call has left the queue.

If the same person hits your paywall again, we do not restart the wait. Once someone has been contacted, resolved, or parked, a new event from them starts a fresh cycle. That cycle starts only after 30 days. Inside that window, hyzl treats it as the same signup.

## Control when the timer starts

The recovery timer counts from a single anchor. By default that is `occurredAt`, or the time we receive the event. Send `startTimerAt` to control it. Send it again to reset it.

### All at the paywall

POST one event when the person abandons the paywall. The timer counts from `occurredAt`, or from receipt time. This is the simplest setup.

```json One event at the paywall theme={null}
{
  "userId": "user_123",
  "phoneNumber": "+13105551212",
  "consent": { "attested": true },
  "occurredAt": "2026-01-01T12:00:00Z"
}
```

### Start at signup, then reset on abandonment

POST at signup with `startTimerAt` set to the signup time. The timer starts then. You can reach people who never reached the paywall. If the person later reaches the paywall and abandons it, POST a second event. Use the same `userId` and a new `startTimerAt`. We reset the timer to that later moment.

```json Signup event theme={null}
{
  "userId": "user_123",
  "email": "person@example.com",
  "startTimerAt": "2026-01-01T12:00:00Z"
}
```

```json Later abandonment event theme={null}
{
  "userId": "user_123",
  "phoneNumber": "+13105551212",
  "consent": { "attested": true },
  "startTimerAt": "2026-01-01T12:30:00Z"
}
```

### Staggered identity, with the Stripe id sent later

POST the signup first with `userId` and `email`. Send the phone number later. Send the Stripe `Customer` id (`cus_...`) in `stripeCustomerId` on later events. Those events carry the same `userId`. Each event may include `startTimerAt` to re-anchor the timer.

```json Signup event theme={null}
{
  "userId": "user_123",
  "email": "person@example.com",
  "startTimerAt": "2026-01-01T12:00:00Z"
}
```

```json Later event with phone and Stripe id theme={null}
{
  "userId": "user_123",
  "phoneNumber": "+13105551212",
  "stripeCustomerId": "cus_ABC123",
  "consent": { "attested": true },
  "startTimerAt": "2026-01-01T12:30:00Z"
}
```

## How many calls, and when

Two settings on the same screen as the offer and the wait time. Both apply to this workflow only, and both can be changed later from **Edit** on the workflow.

**Daily call cap.** The most calls this workflow places in one day. The default is 100. hyzl does not drop signups past the cap. It calls them the next day instead.

**Calling window.** The hours we call, in each member's own local time, every day of the week. The default is 9am to 8pm, and that is also the widest it goes. You can pull either end in, for example 10am to 6pm. You cannot push either end out, and there is no out-of-hours override.

A narrower window does not mean fewer calls, it means later ones. A signup whose wait ends at 8am is held until the window opens rather than dialed early.

<Note>
  Both settings are written to the workflow when you create it, so it runs under
  them from the first call. If you never open them, you get 100 calls a day
  between 9am and 8pm.
</Note>

## The win-back discount

Your win-back offer can carry a discount. hyzl gets that discount to checkout, so you build nothing for it.

After the call, hyzl creates a Stripe Checkout Session on your connected account. It already carries the coupon. hyzl then texts the member a short link. The member taps it and pays the reduced price. hyzl hosts that Checkout Session, so your own checkout page needs no change. You need a connected Stripe account and a live win-back offer.

## Send your own recovery links

By default hyzl builds the win-back link for you, as above. If you would rather send your own, turn that on in the workflow's Revenue Recovery settings.

With it on, send a `recoveryUrl` on the ingest webhook for each person. hyzl texts that link as the win-back link instead of building one. It can be a web-billing link, a Stripe link, or a deep link to your own paywall.

Send the URL on the first event, or on a later event for the same person. A later value replaces an earlier one. The link is opaque to hyzl: we never read it, change it, or add to it. Tie the person to the link on your side, with the same `userId` you already send.

The link must be a valid `https` URL. hyzl rejects anything else with `400 invalid_recovery_url`, and the workflow card names the problem.

When a person has no `recoveryUrl`, one more setting decides what happens. Leave the fallback off, the default, and hyzl parks that person for you to review. Turn the fallback on to let hyzl build the link instead.

hyzl still checks whether the person paid before every send, across every provider you connect. Only the link changes.

## Send your own win-back discount percent

By default hyzl uses the campaign's own win-back discount, as above. If you would rather send your own, turn that on in the workflow's Revenue Recovery settings.

With it on, send a `percentOff` on the ingest webhook for each person. hyzl quotes that percent on the call and in the offer text. On a hyzl-built Stripe link, hyzl also applies it at checkout. A RevenueCat App Store offer code keeps the discount you set in App Store Connect. So on that path hyzl quotes the campaign percent that matches the code instead.

Send it on the first event, or on a later event for the same person. A later value replaces an earlier one.

Only a whole number between 1 and 90 counts as a valid percent. hyzl rejects anything else with `400 invalid_percent_off`, and the workflow card names the problem.

When a person has no percent, one more setting decides what happens. Leave the fallback off, the default, and hyzl parks that person for you to review. Turn the fallback on to let hyzl use the campaign's own percent instead.

Whichever setting you use, hyzl always states a percent in the offer text. When you also send your own recovery link, match your own link to that percent. To override it, send `percentOff` on the webhook instead. On a hyzl-built link, hyzl applies that same percent at checkout too.

## When nobody answers

A person who never answers still gets the offer. Once every dial has gone unanswered, hyzl checks with your provider one final time. If the person has still not paid, hyzl texts them the offer once.

* On **Stripe**, the text carries a discounted checkout link. The link is minted at send time and expires 24 hours later. The expiry is Stripe's own, so it is a real cutoff.
* On **RevenueCat**, the text carries an App Store redemption code from your offer-code pool.

This text needs a positive win-back discount. A 0% workflow has no offer, so it sends nothing after a missed call. If the offer text goes unredeemed, hyzl sends one reminder text about 23 hours later. That is an hour before the claimed deadline, the same as the other recovery triggers. A member who replies to either text reaches your AI receptionist. It can resend a still-valid link on request. See [Offer Texts](/docs/revenue-recovery/the-call/offer-texts) for the send policy and wording.

The default voicemail matches. When a text will follow, it says hyzl will text a link to finish signing up. A voicemail script you edited yourself is never changed.

## Responses

| Status | Code                 | Meaning                                                                                                                                                                                  |
| ------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 200    | `accepted`           | We stored it, but the workflow is switched off, so nothing is scheduled. Normal before you press **Finalize & Launch**. Signups stored while it's off aren't called when you turn it on. |
| 202    | `accepted`           | We stored it and started the wait. This is the success response for a live workflow.                                                                                                     |
| 400    | `invalid_phone`      | `phoneNumber` was sent but could not be read. Use E.164, for example `+13105551212`.                                                                                                     |
| 400    | `malformed_body`     | The body was not a JSON object.                                                                                                                                                          |
| 400    | `no_identifier`      | No `userId`, `email`, phone number, or Stripe customer ID. We need at least one.                                                                                                         |
| 400    | `no_user_id`         | You sent contact details but no durable id we can check a payment against. Send `userId` (your stable user id) or a Stripe customer ID. Email and phone alone are not enough.            |
| 401    | `unauthorized`       | Missing or wrong ingest token. Send it as `Authorization: Bearer <token>`.                                                                                                               |
| 404    | `unknown_slug`       | The URL doesn't match a Signup Recovery workflow. If it's a URL you retired, the body returns your current one as `url`, so point your integration there.                                |
| 405    | `method_not_allowed` | Use POST.                                                                                                                                                                                |

Treat both `200` and `202` as success. Anything else is a real failure worth logging.

On a `5xx` or a network failure, retry the POST. Send a stable `eventId` (for example `signup.id`) so a retry that lands twice is de-duped. We ignore a repeat of an ID we have already processed. Retrying is always safe.

A `202` means we stored the event, not that we called anyone. We wait, check, and call only after both checks pass.

A `200` means we stored it but scheduled nothing, because the workflow is switched off. A draft workflow is off until you press **Finalize & Launch**. So your developer will see this on every request until then. A check written as `if (status !== 202) throw` reports a failure on a request that worked.

Every response also carries `outcome`. It is `created` for a new person. It is `joined` when a later event lands on someone we already have. On a `joined` response, `alreadyContacted` says whether we had already moved past waiting for them. A second event then does not read as a bug. The response also carries `flowEnabled`, `contactable`, and `consentAttested`. It adds a `note` naming each thing currently blocking a call. Those are the fields to assert on in a smoke test.

## Cancel a signup

If someone should no longer be called, POST a cancel. Send it when they opted out, bought outside our flow, or you are removing them. Your cancel URL is your webhook URL with `paywall_recovery_webhook` replaced by `paywall_recovery_cancel_webhook`, and the same slug. Send the same `Authorization: Bearer <ingest token>`.

* Identify the person the same way as the intake: send the durable id you enrolled them with (`userId` or a Stripe `stripeCustomerId`). Email or phone alone is not enough.
* It stops pending outreach only. A signup still inside its wait is stopped, and a call already queued but not yet dialed is cancelled.
* It does nothing once we have already contacted them. A sent offer, a placed call, or a recorded save is left exactly as it is.
* It never counts as a purchase. Cancelling is only ever a stop, so it never shows up as recovered money.

| Status | Code                 | Meaning                                                                                                                                                     |
| ------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 200    | `accepted`           | We processed the cancel. `cancelled` is `true` when we stopped a pending signup or cancelled a queued call, `false` when there was nothing pending to stop. |
| 400    | `no_user_id`         | You sent contact details but no durable id we can find the signup by. Send the `userId` or Stripe customer ID you enrolled it with.                         |
| 400    | `no_identifier`      | No `userId`, `stripeCustomerId`, email or phone number. We need at least one.                                                                               |
| 400    | `malformed_body`     | The body was not a JSON object.                                                                                                                             |
| 401    | `unauthorized`       | Missing or wrong ingest token. Send it as `Authorization: Bearer <token>`.                                                                                  |
| 404    | `unknown_slug`       | The URL doesn't match a Signup Recovery workflow.                                                                                                           |
| 405    | `method_not_allowed` | Use POST.                                                                                                                                                   |

## While a workflow is switched off

A workflow is off before you launch it, and off again whenever you pause it. hyzl stores events you send in the meantime, so they still count in your reporting. But nothing is scheduled for them, and nothing is called.

Turning the workflow on does not call them either. We only call signups that arrive while it is on. A pause is not a queue. A three-day pause does not end in three days of calls landing at once.

This is deliberate. It is the same rule we apply to any signup older than 48 hours. Past that point, a call stops being a nudge about a decision someone is still making. To test a live workflow, turn it on and send a fresh signup.

## Where your signups show up

Every signup you send gets a row in the **Signup recovery** card on **Revenue Recovery → Activity**. The row carries the date, the person, one sentence on what happened to them, and an outcome badge.

That card has its own search, date filter, and outcome filter. None of them touch the cancellation table above it. Click a row to open the person's profile, with the call recording and the transcript. See [Activity Log](/docs/revenue-recovery/analytics/activity-log#signup-recovery).

<Note>
  The Revenue Recovery dashboard shows timestamps in US Pacific Time
  (`America/Los_Angeles`), not your browser's local time.
</Note>

## If it is not working

Check the Signup Recovery workflow card in **Revenue Recovery → Workflows**. It warns you when something is off with the integration and names the exact problem. For example:

* A run of events with no `userId`
* A phone number it could not parse

It also shows how many events the problem affected and when it last happened. Fix the field the warning names, and the next event clears it.

## Paste it into an AI coding assistant

The setup step in the portal also has a developer bundle. One copy button produces a self-contained brief covering:

* The task
* Where the POST goes
* The consent checkbox
* The field table
* A ready request with your real URL and credential filled in
* How to confirm it is working

Paste it into Claude, Cursor, or any AI coding assistant with "do this." It has everything needed without seeing the rest of these docs.
