You are integrating Hyzl paywall recovery into this codebase. Hyzl texts people
who leave our paywall without paying, from our own number, after checking that
they still haven't paid. Your job is to send Hyzl one event at the right moment.
Work in this repo, make the change, and stop before anything that would text a
real customer.
FACTS
- App: {{APP_NAME}} (Hyzl app id {{APP_ID}})
- Endpoint: POST {{WEBHOOK_URL}}
- Auth: header `Authorization: Bearer <server key>`, where the server key is the
one credential that lets our backend post paywall events to Hyzl, read from
the environment variable HYZL_INGEST_TOKEN on our server. Anyone holding it
can make Hyzl text any phone number, so it stays on the server: never print,
log, commit, or hardcode it, and never send it from a browser or a mobile app.
- Payment provider connected in Hyzl: {{PROVIDER}}
- Link mode: {{LINK_MODE}}{{PAYWALL_URL_LINE}}
RULES
1. I copy the server key from the Hyzl Install page. Store it as
HYZL_INGEST_TOKEN with your platform's secret command reading from the
clipboard (macOS `$(pbpaste)`, Windows `Get-Clipboard -Raw`, Linux
`xclip -selection clipboard -o`) so the value never appears in this
conversation, in code, in `.env.example`, in logs or in tests. Ask me to
copy it when you reach this step, confirm the variable exists by name only,
and never print it.
2. The event is sent from our SERVER, never from client code.
3. consent.attested is the real state of whatever consent our app collects,
true or false. Never hardcode true: a wrong true means texting someone who
never agreed. Absent leaves what Hyzl already holds unchanged; an explicit
false revokes.
4. While you work, only send events with `test: true` and MY phone number, or
for my own test account. Do not send events for real users until I say the
app is live in Hyzl.
5. Nothing you add may break the user's flow: the call runs after the response
or in the background, never throws into the UI, has a timeout, and logs
only the HTTP status and Hyzl's `error` code.
TASKS
1. Find the paywall. Locate where the app shows the price and where it can tell
the person left without buying (closed, dismissed, navigated away, or a
short timeout after render). That moment is the trigger. If the app cannot
detect leaving, fire when the paywall renders; Hyzl waits before texting, so
people still deciding are absorbed. Never fire at signup or when a phone
number is collected: that is too early. Tell me which file and function you
chose and why.
2. Find the durable identity.
- RevenueCat: `userId` must be EXACTLY the value passed to
Purchases.logIn(). Anything else silently matches nobody.
- Stripe: send `stripeCustomerId` (cus_…) when we have one, and make sure our
Checkout Session sets `client_reference_id` or `metadata.userId` to the
same `userId` we send Hyzl, so payments can be matched.
Email and phone alone are not enough; Hyzl rejects such events.
3. Find the phone number and consent. Locate where we collect the phone number
and how we already ask permission to text people, with its timestamp if we
store one. If we already collect consent for texts in any form, use it as it
is and send its real state — do not rewrite or redesign it. Only if there is
no consent anywhere, suggest adding a checkbox next to the phone field, and
offer this wording as a starting point I can edit (adjust the company name):
"I agree that {{APP_NAME}} 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}"
Link our real Terms and Privacy Policy where the {placeholders} are. Suggest
leaving the box unchecked by default and keeping the phone field optional,
and store the wording shown and the time it was ticked.
4. Implement a server-side function, e.g. sendHyzlPaywallEvent(user, context),
that POSTs this JSON with the auth header above:
{
"userId": "<durable user id>",
"stripeCustomerId": "<cus_… if Stripe and known>",
"phoneNumber": "<E.164, e.g. +13105551212>",
"firstName": "<first name if known>",
"email": "<email exactly as stored, if known>",
"consent": { "attested": <true|false>, "text": "<wording shown>", "at": "<ISO 8601 when ticked>" },
"eventId": "<stable id for this paywall exit, so a retry is a no-op>",
"occurredAt": "<ISO 8601 or epoch MILLISECONDS>",
"stage": "saw_price",
"price": <number if known>, "currency": "<ISO code if known>",
"context": "<optional: short facts about this person, e.g. onboarding answers; max 1000 characters>",
"detail": "<optional short line, e.g. the trip they searched>",
"recoveryUrl": "<https link to send this person, only if link mode is per_person>"
}
Normalize the phone number to E.164 before sending; drop the event if it
cannot be normalized. Build eventId from the user id plus the paywall
session or the UTC date, so one person hitting the wall four times in an
evening is one event, not four. Timeout 10 seconds. Treat 200 and 202 as
success. On 4xx read the `error` field; the codes are listed under
RESPONSES. Retry only on 5xx or network errors.
5. Wire the trigger from task 1 to that function: client tells our backend
the paywall was shown/left (or the backend already knows), the backend calls
Hyzl. If the phone number arrives later than the paywall, send the event
again with the same userId once you have it; the event carrying the phone
number and consent is the one that starts the wait.
6. Test, in this order:
a. Ask me for my phone number. Send one event with `test: true`, that
number, and consent.attested true. Expect 202 and a text on my phone
within a minute (test events skip the wait and the payment check).
b. Send one normal event for my own test account. Expect 202 with
"status" in the body; nothing is texted until the wait passes and the
app is live.
c. Tell me to open Hyzl's Install page: the Installation check must say the
event was received.
7. Report: files changed, where the trigger fires, how identity and consent are
captured, where you stored the server key (by variable name, never its
value), anything you could not find, and what remains for me (any environment
still missing HYZL_INGEST_TOKEN, turning the app live in Hyzl).
RESPONSES
202 stored (body: person_id, status, contactable, consent_attested,
next_action_at). 200 duplicate eventId, no-op. 400 malformed_body,
invalid_phone, invalid_consent (attested must be a boolean), missing_durable_id
(no userId and no cus_…), invalid_recovery_url (https only, no credentials,
under 2048 chars), invalid_percent_off (1–90), invalid_occurred_at (seconds
sent where milliseconds are expected lands here), test_requires_phone. 401
unauthorized (token). 500 ours, retry.
WHAT HYZL DOES AFTER THE EVENT
With the default text flow, waits 5 minutes, re-checks the billing provider,
and if the person is still unpaid and consented, texts once from our number
as {{APP_NAME}}'s AI rep. Default follow-ups at 1 and 3 days. Flow supports up to 6 steps (texts
or calls), with payment checked before every step. A reply pauses the sequence
and the AI rep answers; a payment or STOP ends it. Quiet hours apply to texts. Hazel answers calls on the same number and can also call people who consented
if calls are turned on in Flow. Calls are off by default.