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

# Send the paywall event

> Connect your server to Hyzl and make sure that events arrive.

A paywall event tells Hyzl that a person saw your price and left without payment.
A server is private software that handles app requests.

You can follow this guide or use the [AI code assistant setup](/install/ai-setup).

## 1. Store the Ingest token

The Ingest token permits your server to send paywall events for one app.
Create it on that app's Install page.

Copy the token, then pass the clipboard value directly to your server host's secret command.
Store it as `HYZL_INGEST_TOKEN`.

| System             | Clipboard reader                |
| ------------------ | ------------------------------- |
| macOS              | `$(pbpaste)`                    |
| Windows PowerShell | `Get-Clipboard -Raw`            |
| Linux              | `xclip -selection clipboard -o` |

Do not run a clipboard reader by itself if it prints the token.
Put the reader inside your host's secret command.

Never put the Ingest token in these places:

* A browser or mobile app.
* An AI chat.
* A code file or `.env.example` file.
* A test, log, or screenshot.

Hyzl shows the token once and stores only a one-way copy.
That copy cannot reveal the original token.
If the token becomes visible, create a new token on the Install page.

A new token replaces the old token at once.
Update every server environment that sends events for this app.
A server with the stale token gets `401 unauthorized` and this message:

```text theme={null}
That ingest token does not match an app.
```

## 2. Send the event

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

| Moment                                             | Fit         | Reason                                                 |
| -------------------------------------------------- | ----------- | ------------------------------------------------------ |
| The person leaves the paywall without payment      | Recommended | The person saw your price and did not buy.             |
| 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.                 |
| The app collects a phone number before the paywall | Too early   | The person did not show clear purchase interest.       |

If your app cannot detect the exit, send the event when the paywall appears.
Do not send it at signup.

Your first complete event needs these facts:

* A durable identity: [`userId`](/reference/paywall-event#fields) or [`stripeCustomerId`](/reference/paywall-event#fields).
* The real consent choice in [`consent.attested`](/reference/paywall-event#fields).
* A phone number in [`phoneNumber`](/reference/paywall-event#fields) before Hyzl can contact the person.

A durable identity identifies the same person over time.
Email and phone alone cannot prove whether that person paid.

For RevenueCat, [`userId`](/reference/paywall-event#fields) must equal the value passed to `Purchases.logIn()`.
Any other value can make a paid person appear unpaid.

For Stripe, send [`stripeCustomerId`](/reference/paywall-event#fields) when you know it.
It must start with `cus_`.
Also set `client_reference_id` or `metadata.userId` on the Checkout Session or Payment Link.
Use the same [`userId`](/reference/paywall-event#fields) in the paywall event.

Use E.164 format for [`phoneNumber`](/reference/paywall-event#fields), such as `+13105551212`.
E.164 is the full number with its country code.

Send the person's real choice in [`consent.attested`](/reference/paywall-event#fields).
Never set it to `true` for every person.

The examples also use these optional fields:

* [`firstName`](/reference/paywall-event#fields) and [`email`](/reference/paywall-event#fields) add known contact details.
* [`consent.text`](/reference/paywall-event#fields) records the words that the person saw.
* [`consent.at`](/reference/paywall-event#fields) records the consent time.
* [`eventId`](/reference/paywall-event#fields) makes a retry safe.
* [`occurredAt`](/reference/paywall-event#fields) records when the person left the paywall.
* [`stage`](/reference/paywall-event#fields) describes the point that the person reached.
* [`recoveryUrl`](/reference/paywall-event#fields) supplies a separate link for that person when your link mode needs one.

See the [paywall event reference](/reference/paywall-event#fields) for every available field.
Use only the identity fields that apply to your payment provider.
Replace every sample value before you send a real event.

Send a `POST` request to this address:

```text theme={null}
https://yvklwfalxxeslusmnpwi.supabase.co/functions/v1/paywall-event
```

<Tabs>
  <Tab title="curl">
    ```bash theme={null}
    curl --request POST \
      --max-time 10 \
      --header "Authorization: Bearer $HYZL_INGEST_TOKEN" \
      --header "Content-Type: application/json" \
      --data '{
        "userId": "user_123",
        "stripeCustomerId": "cus_example123",
        "phoneNumber": "+13105551212",
        "firstName": "Sam",
        "email": "person@example.com",
        "consent": {
          "attested": true,
          "text": "The exact consent words that Sam saw",
          "at": "2026-09-17T14:30:00Z"
        },
        "eventId": "paywall_session_123",
        "occurredAt": "2026-09-17T14:30:00Z",
        "stage": "saw_price"
      }' \
      https://yvklwfalxxeslusmnpwi.supabase.co/functions/v1/paywall-event
    ```
  </Tab>

  <Tab title="Node.js">
    ```js theme={null}
    // SERVER ONLY. Call this after your app receives its response.
    export async function sendHyzlPaywallEvent(user, paywallVisit) {
      const token = process.env.HYZL_INGEST_TOKEN;
      if (!token) return { ok: false, retryable: false };

      try {
        const response = await fetch(
          "https://yvklwfalxxeslusmnpwi.supabase.co/functions/v1/paywall-event",
          {
            method: "POST",
            signal: AbortSignal.timeout(10_000),
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              userId: user.id,
              stripeCustomerId: user.stripeCustomerId,
              phoneNumber: user.phoneE164,
              firstName: user.firstName,
              email: user.email,
              consent: {
                attested: user.contactConsent === true,
                text: user.contactConsentText,
                at: user.contactConsentAt,
              },
              eventId: paywallVisit.id,
              occurredAt: paywallVisit.occurredAt,
              stage: "saw_price",
              recoveryUrl: paywallVisit.recoveryUrl,
            }),
          },
        );

        const ok = response.status === 200 || response.status === 202;
        if (!ok) {
          const body = await response.json().catch(() => ({}));
          console.warn("Hyzl", { status: response.status, error: body.error });
        }
        return { ok, retryable: response.status >= 500 };
      } catch {
        return { ok: false, retryable: true };
      }
    }
    ```
  </Tab>

  <Tab title="Swift server">
    ```swift theme={null}
    // SWIFT SERVER ONLY. Never put this request or token in an iOS app.
    import Foundation

    guard let token = ProcessInfo.processInfo.environment["HYZL_INGEST_TOKEN"] else {
      return
    }

    var request = URLRequest(
      url: URL(string: "https://yvklwfalxxeslusmnpwi.supabase.co/functions/v1/paywall-event")!
    )
    request.httpMethod = "POST"
    request.timeoutInterval = 10
    request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONSerialization.data(withJSONObject: [
      "userId": user.id,
      "stripeCustomerId": user.stripeCustomerId,
      "phoneNumber": user.phoneE164,
      "firstName": user.firstName,
      "email": user.email,
      "consent": [
        "attested": user.contactConsent,
        "text": user.contactConsentText,
        "at": user.contactConsentAt,
      ],
      "eventId": paywallVisit.id,
      "occurredAt": paywallVisit.occurredAt,
      "stage": "saw_price",
      "recoveryUrl": paywallVisit.recoveryUrl,
    ])

    let (_, response) = try await URLSession.shared.data(for: request)
    let status = (response as? HTTPURLResponse)?.statusCode
    // 200 and 202 mean success. Retry only network errors and 5xx responses.
    ```
  </Tab>
</Tabs>

Do not make the person wait for Hyzl.
Let the app continue while your server sends the event.
Use a ten-second timeout.

Treat `200` and `202` as success.
A `5xx` response means that Hyzl had a server error.
Retry only after a network problem or a `5xx` response.
A `4xx` response means that the request needs a correction.
Read the linked [`error`](/reference/paywall-event#responses) code and fix the request.

If the phone number arrives later, send the facts in two steps.
Use the same linked [`userId`](/reference/paywall-event#fields) in both requests.

```js theme={null}
// Step 1: identify the person before the phone number is known.
await postToHyzl({
  userId: user.id,
  email: user.email,
});

// Step 2: send the phone number and real consent state at the paywall.
await postToHyzl({
  userId: user.id,
  phoneNumber: user.phoneE164,
  consent: { attested: user.contactConsent === true },
});
```

The second event starts the wait because it carries the phone number and consent.

## 3. Confirm that the event arrived

The Installation check proves that your server reached Hyzl with an event for this app.

The Test text button alone does not count as a completed Installation check.
It sends a text from Hyzl, so it tests your Hyzl number and Flow instead of your server code.
A test text can arrive while your server integration still sends nothing.

Send one normal event for your own test account:

1. Use your test account's durable identity and a phone number that you control.
2. Send the real consent state for that account.
3. Omit the linked [`test`](/reference/paywall-event#fields) field or set it to `false`.
4. Use a new linked [`eventId`](/reference/paywall-event#fields).
5. Make sure that the response is `202` with a linked [`status`](/reference/paywall-event#responses) value.
6. Open the Install page for the same app.
7. Select **Check installation**.
8. Make sure that the Installation check says that Hyzl received the event.

A normal event does not send contact until the Flow wait ends and the app is Live.
Do not use a real person's account for this check.
