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

# Signup Recovery

> Call people who sign up but never pay, verified against RevenueCat or Stripe before every call.

Signup Recovery calls people who create an account in your app and never buy. Your backend posts us the signup. We wait, then check RevenueCat or Stripe to see whether they've purchased since. If they still have not, we text a heads-up and call them 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>

## 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`.                      |
| `appUserId`                                                       | **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()`. |
| `email` or `stripeCustomerId`                                     | **Conditional**, one of the two required if you use Stripe      | We cannot check whether this person has paid, so we will not call them.                                                         |
| `occurredAt`                                                      | Optional                                                        | The wait is measured from when we receive the event instead of when it happened.                                                |
| `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.                                           |

### The minimum event

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

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

The identity field is what lets us confirm the person never paid. RevenueCat answers only for an `app_user_id`. Stripe answers only for a `Customer` id or an email. hyzl stores an event that has a phone number but no identity, never verifies it, and never calls the person.

<Warning>
  `appUserId` 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 `appUserId` 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.

## 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 '{
      "appUserId": "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()
      appUserId: 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()
    "appUserId": 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>

## 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 `appUserId` on every step. We assemble the record for you, joining on `appUserId` 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({ appUserId: user.id, email: user.email })

// Step 2, at the paywall, once you have the phone number and consent. Same appUserId.
// This step starts the wait, so place it at the paywall, not at signup.
await postToHyzl({
  appUserId: 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.

## 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 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.

## 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>

## 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 `appUserId`, `email`, phone number, or Stripe customer ID. We need at least one.                                                                                                      |
| 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.                                                                                                                                                                                |

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

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 `flowEnabled`, `contactable` and `consentAttested`, plus a `note` naming each thing currently blocking a call. Those are the fields to assert on in a smoke test.

## 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).

## 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 `appUserId`
* 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.
