Skip to content

Inbound revoke webhook from last1.id

Endpoint: POST /functions/v1/revoke-from-last1 Source: 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:

  1. Verifies the HMAC signature on X-Last1-Signature.
  2. Idempotency-claims via INSERT ON CONFLICT DO NOTHING on last1_webhook_deliveries.delivery_id (= X-Last1-Delivery).
  3. Finds the RadioCheck user whose users.last1_identity_id matches the event's data.identity_id.
  4. 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.
  5. Updates the action column on the delivery row to one of identity_unlinked, no_matching_user, unsupported_event, idempotent_replay, or error for audit.
  6. 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:

supabase secrets set LAST1_INBOUND_WEBHOOK_SECRET=<base64> --project-ref <ref>

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

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' → see last_error; the row stays on pending-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

  1. Generate a new base64 secret (openssl rand -base64 32).
  2. On last1.id: write the new secret to app_clients.webhook_secret_b64 and webhook_secret_rotated_at. The signer supports a rotation window where last1.id sends t=...,v1=<new>,v1=<old> so both verify.
  3. 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.
  4. After last1.id's rotation window expires the old secret stops signing and we're back to single-secret operation.

Testing locally

deno test \
  supabase/functions/revoke-from-last1/ \
  --allow-net --allow-env

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.ts for import { 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.