Inbound revoke webhook from last1.id¶
Endpoint:
POST /functions/v1/revoke-from-last1Source: last1.id Connected Apps revoke flow Migration:supabase/migrations/20260523000000_last1_revoke_webhook_deliveries.sql
What it does¶
When a user revokes consent or unlinks RadioCheck on
https://last1.id (the Connected Apps page on last1.id), last1.id
POSTs a signed event to this Edge Function so RadioCheck can drop
the user's local Last1 linkage immediately. Without this push
channel, public.users.last1_identity_id would stay populated
until the user explicitly disconnected from inside the app — a
visible lag for a product whose pitch is "you are in control of your
data."
The handler:
- Verifies the HMAC signature on
X-Last1-Signature. - Idempotency-claims via INSERT ON CONFLICT DO NOTHING on
last1_webhook_deliveries.delivery_id(=X-Last1-Delivery). - Finds the RadioCheck user whose
users.last1_identity_idmatches the event'sdata.identity_id. - Sets
users.last1_identity_id = NULL. The Supabase session is left intact — only the Last1 linkage is severed — so re-linking is a single tap. - Updates the
actioncolumn on the delivery row to one ofidentity_unlinked,no_matching_user,unsupported_event,idempotent_replay, orerrorfor audit. - Returns
204 No Content. Response bytes are identical across matched-user / unknown-user branches so the endpoint is not a user-existence oracle.
What it does NOT do (vs LastVet's handler)¶
RadioCheck uses the Supabase JWT bridge (lib/last1Id.ts), not
the full OAuth client-credentials flow. There is no per-user OAuth
refresh token persisted in RadioCheck, so the handler does not:
- clear any encrypted ciphertext columns (none exist),
- call back to last1.id's
/oauth/revoke(already done on the last1.id side before the webhook fires), - log the user out of RadioCheck.
The single side-effect is UPDATE users SET last1_identity_id = NULL.
Configuration¶
| Var | Where | Required | Notes |
|---|---|---|---|
LAST1_INBOUND_WEBHOOK_SECRET |
Edge Function secrets | Yes (else 503) | Base64-encoded HMAC secret. Provisioned by last1.id and shared during partner onboarding. Distinct from the OUTBOUND LAST1_WEBHOOK_SECRET already used by stripe-webhook for bundle events. |
SUPABASE_URL |
Edge Function secrets | Yes | Standard. |
SUPABASE_SERVICE_ROLE_KEY |
Edge Function secrets | Yes | Required to bypass RLS on last1_webhook_deliveries and write users. |
Set with:
The function is registered with verify_jwt = false in
supabase/config.toml because last1.id doesn't send a Supabase
user JWT — the HMAC over the body IS the authentication.
Last1-side configuration¶
Once this Edge Function is deployed, set the partner row on last1.id (see PARTNER_WEBHOOKS.md operator notes):
UPDATE app_clients
SET webhook_url = 'https://<project>.supabase.co/functions/v1/revoke-from-last1',
webhook_secret_b64 = '<base64 secret>',
webhook_enabled = true,
webhook_secret_rotated_at = NOW()
WHERE app_id = 'radiocheck';
Operating notes¶
"User says revoke didn't unlink RadioCheck"¶
SELECT delivery_id, event_type, identity_id, action, last_error,
received_at, processed_at
FROM public.last1_webhook_deliveries
WHERE identity_id = '<user-last1-identity-id>'
ORDER BY received_at DESC
LIMIT 5;
action = 'identity_unlinked'→ done; their app cache is stale, pull-to-refresh.action = 'no_matching_user'→ they revoked an identity that isn't linked to a RadioCheck user. Likely they're looking at the wrong app on last1.id, or they re-linked with a different user.action = 'error'→ seelast_error; the row stays onpending-style state and last1.id retries on backoff.
Re-run a stuck delivery¶
The last1.id drainer retries failed deliveries automatically. If
you need to force a re-attempt, ask last1.id to bump
webhook_deliveries.next_attempt_at.
To re-process locally (after fixing root cause), delete the
delivery row in last1_webhook_deliveries and ask last1.id to
re-send — the absent row will let the INSERT succeed.
Secret rotation¶
- Generate a new base64 secret (
openssl rand -base64 32). - On last1.id: write the new secret to
app_clients.webhook_secret_b64andwebhook_secret_rotated_at. The signer supports a rotation window where last1.id sendst=...,v1=<new>,v1=<old>so both verify. - On RadioCheck:
supabase secrets set LAST1_INBOUND_WEBHOOK_SECRET=<new>then deploy. Until the deploy finishes, our handler accepts only the NEW secret; last1.id's dual-signing covers that window. - After last1.id's rotation window expires the old secret stops signing and we're back to single-secret operation.
Testing locally¶
Should report 24 passed | 0 failed (11 signature + 13 handler).
For an end-to-end smoke test against staging, ask the last1.id team
to send a synthetic consent.revoked to the staging Edge Function
URL with a test identity_id from staging data.
Pending follow-ups¶
- Swap
verifyWebhookSignature.tsforimport { verifyWebhookSignature } from "@last1id/sdk"once the SDK ships to npm (today the SDK is workspace-only). The byte contract is identical; this is a code-hygiene swap. - Operator admin UI for partner webhook config — today this lives in SQL only on the last1.id side.