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

# Paywall event reference

> See every field, response, and example for a paywall event.

A server is private software that handles app requests.
A paywall event is a request from that server after a person leaves your paywall.
It gives Hyzl the facts that it needs for recovery.

Start with [Send the paywall event](/install/send-the-event) for a short setup guide.
You can also [use an AI code assistant](/install/ai-setup).

## Address

```
POST https://yvklwfalxxeslusmnpwi.supabase.co/functions/v1/paywall-event
Authorization: Bearer <your server key>
Content-Type: application/json
```

## Protect the ingest token

The ingest token is the secret that permits your server to send paywall events.
On the Install page, this appears as **Server key**.

1. Generate the ingest token on the Install page.
2. Copy the key to your clipboard.
3. Store it as `HYZL_INGEST_TOKEN` in your server environment.
4. Use your platform's secret command to read the clipboard.

On macOS, use `$(pbpaste)`. On Windows, use `Get-Clipboard -Raw`.
On Linux, use `xclip -selection clipboard -o`.

Do not put this key in a chat, code file, test, log, or screenshot.
Anyone with the key can send events for your app and use your Hyzl number.

If the key becomes visible, generate a new key on the Install page.
A new key replaces the old key at once.

## Choose the right moment

Send the event after a signed-in person sees your price and leaves without payment.
This moment starts recovery.

| Where                                              | Fit             | Why                                                             |
| -------------------------------------------------- | --------------- | --------------------------------------------------------------- |
| The paywall is shown and they leave without buying | **Recommended** | They saw your price and did not buy. Recovery fits this moment. |
| The paywall appears                                | Good            | The Flow wait gives the person time to finish payment.          |
| Signup completes                                   | Too early       | The person did not see your price yet.                          |
| A phone number is collected, before the paywall    | Too early       | Same problem, one step sooner.                                  |

If you collect the phone number later, send another event with the same `userId`.
The event with the phone number and consent starts the wait.

## Fields

| Field                              | Requirement                                                                   | What happens without it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ---------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userId` **or** `stripeCustomerId` | **Required** (one of them)                                                    | `400 missing_durable_id`. Email and phone alone cannot verify a payment, so we reject the event rather than risk texting someone who already paid. `userId` must be the same id your app passes to `Purchases.logIn()`. A `stripeCustomerId` must start with `cus_`. Minimum 5 characters.                                                                                                                                                                                                                                                                                         |
| `phoneNumber`                      | **Required by the final step.** May arrive on a later event.                  | Nobody can be contacted. The person is stored and shown as waiting for a phone number. E.164, e.g. `+13105551212`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `consent.attested`                 | **Required.** Send the real state of whatever you collect, including `false`. | Absent leaves whatever we already hold for this person unchanged. An explicit `false` REVOKES consent: outreach stops immediately. A non-boolean is `400 invalid_consent`.                                                                                                                                                                                                                                                                                                                                                                                                         |
| `consent.text`                     | Optional                                                                      | The exact wording you showed. Stored with the person as your TCPA evidence trail.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `consent.at`                       | Optional                                                                      | When they ticked it. ISO 8601.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `email`                            | Optional                                                                      | A positive-only finder. A match resolves a Stripe customer for us to check; email never keys the person, because it is reassigned and is not unique in Stripe. Send it exactly as it is stored on your side — Stripe matches customer email case-sensitively, so we query both your spelling and its lowercase form.                                                                                                                                                                                                                                                               |
| `firstName`                        | Optional                                                                      | The text uses "there" instead of their name. Never used to match people.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `eventId`                          | Optional                                                                      | Send one to make retries safe. A repeat of an id we have processed is a `200` no-op.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `occurredAt`                       | Optional                                                                      | The wait is measured from when we receive the event instead of when it happened. ISO 8601 or epoch **milliseconds**.                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `startTimerAt`                     | Optional                                                                      | A later moment when the timer must start. Hyzl uses the later of `occurredAt` and `startTimerAt`. Re-sending it resets the timer.                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `stage`                            | Optional                                                                      | The text uses generic wording. `saw_value`, `saw_price` and `pre_offer` tailor it; any other label passes through as context.                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `price`, `currency`                | Optional                                                                      | `{price}` renders blank and the phrase around it is dropped.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `recoveryUrl`                      | Optional                                                                      | Your own https link, texted verbatim. We never read, change or add anything to it. A later value replaces an earlier one. Not https, or with embedded credentials, or over 2048 characters: `400 invalid_recovery_url`.                                                                                                                                                                                                                                                                                                                                                            |
| `percentOff`                       | Optional                                                                      | Your win-back percent, a whole number 1–90. Outside that: `400 invalid_percent_off`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `offerLinks`                       | Optional                                                                      | For Auto mode: the discount tiers your checkout can honour for this person, as whole percents 0–90 mapped to your own https links, e.g. `{"0": "https://…/full", "33": "https://…/33", "66": "https://…/66"}`. `"0"` is full price. Hazel may move the person to one of these tiers (never above your app's offer, never below what they already hold) and then texts that tier's link. Each link follows the `recoveryUrl` rules. A later event with `offerLinks` replaces them. Not an object, a key that is not a whole percent 0–90, or a bad link: `400 invalid_offer_links`. |
| `context`                          | Optional                                                                      | A short string of facts about this person that helps the AI rep, e.g. their onboarding answers ("Goal: run a 10K. Blocker: price."). Trimmed; up to 1000 characters. Given to the AI rep on texts and calls as information about the person, never as instructions, so send facts rather than directions. A later event with `context` replaces it. Not a string, or over 1000 characters: `400 invalid_context`.                                                                                                                                                                  |
| `detail`                           | Optional                                                                      | A short line for your message templates, used as `{detail}` in Flow texts, e.g. the trip they searched ("LA → Tokyo, business class, November"). Trimmed; up to 80 characters; no links. The AI rep also sees it as a fact about the person. A later event with `detail` replaces it. Not a string, over 80 characters, or containing a link: `400 invalid_detail`.                                                                                                                                                                                                                |
| `test`                             | Optional                                                                      | **For trying the flow, not for real signups.** `true` texts the number on this event within a minute, skipping the wait, the paid check and your app's on/off switch. It **must** include `phoneNumber` on the same event (`400 test_requires_phone`) and it never falls back to a number we already hold — otherwise a test against an existing record would text a real customer. Send a number you control. Never send `true` on a real signup.                                                                                                                                 |

Hyzl ignores fields that do not appear in this table.

### Match each person to a payment

For RevenueCat, `userId` must equal the value that your app gives to `Purchases.logIn()`.
The wrong value can make a person appear unpaid after a purchase.

For Stripe, send `stripeCustomerId` when you know it.
Also set `client_reference_id` or `metadata.userId` on the Checkout Session or Payment Link.
Use the same `userId` in the paywall event.

Hyzl checks each available Stripe identifier before contact.
If a Stripe check fails, Hyzl waits instead of treating the person as unpaid.

### Give Hazel a link for each offer

Use `offerLinks` if your checkout has a different secure link for each allowed discount.
Each key is a whole discount percent from 0 through 90.
The `0` key means the regular price.

```json theme={null}
{
  "offerLinks": {
    "0": "https://example.com/checkout/full-price",
    "25": "https://example.com/checkout/25-off",
    "50": "https://example.com/checkout/50-off"
  }
}
```

Hyzl uses only the links that you send for that person.
Each link must follow the same rules as `recoveryUrl`.

## Consent

Consent is a person's recorded permission for contact by text or call.
Send the real state of your current consent choice.

If you do not ask for consent, add an unchecked box beside your optional phone field.
Edit this example for your business and legal requirements.

Example wording:

> 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. An AI may answer
> calls, and calls may be recorded and transcribed. Consent is not a condition of purchase. Message
> frequency varies. Message and data rates may apply. Reply STOP to opt out.
> Reply HELP for help. \{Terms link} · \{Privacy link}

Replace the placeholders with links to your Terms and Privacy Policy.

Send the real consent state as `consent.attested`, either `true` or `false`.
Never set it to `true` for every person.

Keep the box clear by default. Keep the phone field optional.
Hyzl stores a person without consent, but Hyzl does not contact that person.

## Examples

### curl

```bash theme={null}
curl -X POST https://yvklwfalxxeslusmnpwi.supabase.co/functions/v1/paywall-event \
  -H "Authorization: Bearer {{YOUR_INGEST_TOKEN}}" \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "user_123",
    "phoneNumber": "+13105551212",
    "email": "person@example.com",
    "firstName": "Sam",
    "stage": "saw_price",
    "consent": { "attested": true },
    "eventId": "paywall_abc123"
  }'
```

### Node

```js theme={null}
// Call this at the paywall, when the person does not buy.
await fetch("https://yvklwfalxxeslusmnpwi.supabase.co/functions/v1/paywall-event", {
  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: paywallView.id,
  }),
});
```

### Swift

```swift theme={null}
// Call this at the paywall, when the person does not buy.
var request = URLRequest(url: URL(string: "https://yvklwfalxxeslusmnpwi.supabase.co/functions/v1/paywall-event")!)
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,
  "firstName": user.firstName,
  // The real state of your consent checkbox, not a hardcoded true
  "consent": ["attested": user.contactConsent],
  "eventId": paywallView.id,
])

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

### Send in two steps

```js 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.
await postToHyzl({
  userId: user.id,
  phoneNumber: user.phoneE164,
  consent: { attested: user.contactConsent === true },
});
```

## Responses

| Status | `error`                | Meaning                                                                                                                                          |
| ------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| 202    | None                   | Stored. `outcome` is `created` for a new person or `joined` for an existing person.                                                              |
| 200    | None                   | A repeated `eventId`. `outcome` is `duplicate`, and Hyzl makes no change.                                                                        |
| 400    | `malformed_body`       | The body was not a JSON object.                                                                                                                  |
| 400    | `invalid_phone`        | `phoneNumber` was sent but could not be read as E.164.                                                                                           |
| 400    | `invalid_consent`      | `consent` was not an object, or `consent.attested` was not a boolean.                                                                            |
| 400    | `missing_durable_id`   | No `userId` and no `cus_…`. Email or phone alone is not enough.                                                                                  |
| 400    | `invalid_recovery_url` | `recoveryUrl` was not a valid https URL.                                                                                                         |
| 400    | `invalid_percent_off`  | `percentOff` was not a whole number 1–90.                                                                                                        |
| 400    | `invalid_offer_links`  | `offerLinks` was not an object of whole percents 0–90 to https links.                                                                            |
| 400    | `invalid_context`      | `context` was not a string, or was over 1000 characters after trimming.                                                                          |
| 400    | `invalid_detail`       | `detail` was not a string, was over 80 characters after trimming, or contained a link.                                                           |
| 400    | `invalid_occurred_at`  | `occurredAt` or `startTimerAt` could not be read, or was outside 2020–2100. Sending Unix **seconds** where milliseconds are expected lands here. |
| 400    | `test_requires_phone`  | `test: true` without a `phoneNumber` on the same event.                                                                                          |
| 401    | `unauthorized`         | No token: `Send your ingest token as Authorization: Bearer <token>.`                                                                             |
| 401    | `unauthorized`         | Wrong token: `That ingest token does not match an app.`                                                                                          |
| 405    | `method_not_allowed`   | Use POST.                                                                                                                                        |
| 501    | `sdk_not_available`    | `sdk-event` only. See below.                                                                                                                     |
| 500    | `internal_error`       | Ours. Retry.                                                                                                                                     |

Treat 200 and 202 as success.

A 202 response also includes these fields:

* `person_id`: the Hyzl record for the person.
* `status`: the person's current recovery state.
* `contactable`: whether Hyzl has a phone number.
* `consent_attested`: the consent state that Hyzl stored.
* `next_action_at`: the next planned action time, or `null`.

A 202 response means that Hyzl stored the event. It does not mean that Hyzl sent a text.

## Send from your server

Do not send this event from a browser or mobile app.
A public app can expose the ingest token to other people.

The future `sdk-event` endpoint returns `501 sdk_not_available` and is not deployed.
Send all paywall events to `paywall-event` from your server.

## What happens after the event

1. Hyzl stores the event.
2. Hyzl waits for the first step in your Flow.
3. Hyzl checks the payment provider before each step.
4. Hyzl stops recovery if the person paid or sent `STOP`.
5. Hyzl sends the step if the person remains eligible.

The default Flow waits five minutes, then sends one text.
Its later text steps wait one day and three days.

Quiet hours use the time zone for your app, not the person's location.
The default quiet hours are 9 p.m. to 9 a.m. in that app time zone.
Hyzl holds scheduled texts until quiet hours end.

Call steps use their own calling window.
Outbound calls are off by default and only call United States and Canada numbers.

A reply pauses the Flow for 24 hours while Hazel talks with the person.
Hyzl resumes the Flow only after the conversation stops and the person remains unpaid.
