Last1 ID — Partner Webhooks¶
Push-based notifications for revoke / unlink / delete events. Closes the "I revoked but the partner kept working for an hour" gap.
Why this exists¶
OAuth refresh-token revocation is pull-based: the partner discovers
the revoke when their next refresh attempt fails with invalid_grant.
That gap is up to the access-token TTL (1 hour by default). For an
identity platform whose entire value proposition is "you are in control
of your data", a 60-minute lag between the user clicking Revoke and
the partner actually shutting off is unacceptable.
Partner webhooks add a push channel: the moment a user revokes, last1.id POSTs a signed event to your registered URL. Your job is to drop the local session (and any cached access tokens) on receipt.
You should treat webhooks as the fast path and refresh-token failures as the safety net. Both must work; webhooks make the common case instant.
Event types¶
| event_type | When it fires |
|---|---|
consent.revoked |
User revoked a specific consent grant at https://auth.last1.id/connected-apps. |
app_link.revoked |
User clicked "Disconnect" on your app's tile. |
identity.deleted |
User initiated account deletion. (future — not emitted today.) |
For Phase 1c, the only two events you need to handle are
consent.revoked and app_link.revoked. Treat both identically:
immediately drop every session you have for that identity_id.
Registration¶
Today: send your operator at last1.id three things:
- HTTPS endpoint URL (e.g.
https://last.vet/api/webhooks/last1). - Acknowledgement that you understand the security model below.
- Confirmation that you want webhooks enabled (
webhook_enableddefaults tofalseso we don't fire blind).
We'll write the URL into app_clients.webhook_url, generate a 32-byte
HMAC secret into webhook_secret_b64, return it to you once, and
set webhook_enabled = true.
A self-serve UI for this lives in the operator dashboard roadmap; for now it's a manual op.
Payload shape¶
Every delivery is a POST with Content-Type: application/json and a
body matching:
{
"event_id": "01HXP3T9CK7H9VYJ0H4M0H8Q6F",
"event_type": "consent.revoked",
"issued_at": "2026-05-22T20:00:00.000Z",
"issuer": "https://auth.last1.id",
"data": {
"identity_id": "f7e0c5d8-2c4e-4b3a-8a5d-9b1e6c7f8a02",
"app_id": "lastvet",
"scopes_revoked": ["openid", "credentials:read"],
"source": "user_initiated_app_revoke",
"revoked_at": "2026-05-22T20:00:00.000Z",
"revoked_token_count": 2
}
}
Headers¶
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Last1-Event |
The event_type (also in body — use whichever is easier). |
X-Last1-Delivery |
A UUID. Use this as your idempotency key. |
X-Last1-Signature |
t=<unix>,v1=<hex> Stripe-style HMAC-SHA256. See verification below. |
User-Agent |
last1-id/webhook-drainer (+https://last1.id) |
Verifying the signature¶
The signature is computed as:
Critical: you MUST hash against the exact bytes of the request
body. Do not parse the JSON, re-stringify, and then hash — JSON
serialisation is not byte-stable across runtimes and the signature will
not verify. Most frameworks support reading the raw body before
JSON-parsing it (Express: bodyParser.json({ verify: ... })).
Reference: Node.js (Express)¶
import express from "express";
import { verifyWebhookSignature } from "@last1id/sdk"; // SDK exposes this helper
const app = express();
// Buffer the raw bytes so we can verify the signature later.
app.use(
"/api/webhooks/last1",
express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = buf.toString("utf8");
},
}),
);
app.post("/api/webhooks/last1", async (req, res) => {
try {
verifyWebhookSignature(
process.env.LAST1_WEBHOOK_SECRET!,
req.header("X-Last1-Signature"),
(req as any).rawBody,
// Optional: tighten the replay window. Default is 300s.
// { toleranceSeconds: 120 },
);
} catch (err) {
return res.status(401).json({ error: "invalid_signature" });
}
const { event_type, data } = req.body;
const deliveryId = req.header("X-Last1-Delivery");
// Idempotency: skip if we've seen this delivery_id before.
if (await wasAlreadyProcessed(deliveryId)) return res.status(204).end();
if (event_type === "consent.revoked" || event_type === "app_link.revoked") {
await dropAllSessionsForIdentity(data.identity_id);
}
await markProcessed(deliveryId);
// 2xx tells last1.id to stop retrying. Use 204 (No Content).
res.status(204).end();
});
Reference: raw Node crypto (no SDK)¶
If you don't want to take a dependency on @last1id/sdk:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySignature(secretB64: string, header: string, rawBody: string) {
const key = Buffer.from(secretB64, "base64");
const parts = header.split(",").map((p) => p.trim());
const t = parts.find((p) => p.startsWith("t="))?.slice(2);
const v1 = parts
.filter((p) => p.startsWith("v1="))
.map((p) => p.slice(3));
if (!t || v1.length === 0) throw new Error("malformed signature header");
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (age > 300) throw new Error("stale timestamp");
const expected = createHmac("sha256", key)
.update(`${t}.${rawBody}`, "utf8")
.digest();
for (const candidate of v1) {
const cand = Buffer.from(candidate, "hex");
if (cand.length === expected.length && timingSafeEqual(cand, expected)) {
return;
}
}
throw new Error("signature mismatch");
}
Response semantics¶
Reply with any 2xx (preferably 204 No Content) to ACK the delivery.
| Your response | What last1.id does |
|---|---|
2xx |
Mark delivered. Stop. |
4xx (excl. 408, 429) |
Treat as transient and retry. Recover by deploying a fix. |
408, 429, 5xx |
Treat as transient and retry. |
| network error / timeout (10s) | Treat as transient and retry. |
Retry schedule (≈ 6 attempts over ~32 hours): 30s → 2m → 10m → 1h → 6h → 24h then dead-letter.
Dead-lettered deliveries are visible to our ops team for triage. We'll reach out if you accumulate dead letters.
Idempotency¶
We may retry a successful delivery if our ACK got lost — for example a
network blip between your 2xx response and our DB update. Always
de-dupe on X-Last1-Delivery.
A simple Postgres-based approach:
CREATE TABLE webhook_processed (
delivery_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- INSERT ON CONFLICT DO NOTHING; if 0 rows affected, skip the work.
Security expectations¶
MUST¶
- Verify the signature on every request before doing any work. An attacker who learns your webhook URL could otherwise spam revokes.
- Enforce the replay window (default 5 minutes). A leaked request body should not be replayable an hour later.
- Reply within 10 seconds. Long-running work goes on a queue; the handler itself is a fast "verified → enqueue → 204".
- Treat the secret as Last1-ID-equivalent. Anyone with the secret can forge revoke notifications for your app. Store it in a secret manager, not in plain-text config files.
SHOULD¶
- Log every delivery with
X-Last1-Deliveryso support tickets can cross-reference both sides. - Alert on unverified-signature counters spiking — that's an attacker probing your endpoint, or us shipping a bug.
MUST NOT¶
- Do not extend the access-token TTL as a workaround for handling webhooks. Refresh-token revocation is the safety net; raising the TTL just widens the gap webhooks were built to close.
- Do not block on this webhook in the user-facing request path. If your handler is offline we will retry; the partner UI must not depend on real-time delivery to function.
Secret rotation¶
To rotate the secret:
- Tell your operator at last1.id you want to rotate.
- They generate a new secret and ENABLE A ROTATION WINDOW (default 24h).
During this window, every delivery is signed with BOTH the old
and the new secret, packed as
t=<unix>,v1=<old>,v1=<new>. - Update your partner config to the new secret.
- After the window closes, deliveries are signed with the new secret only.
The verification reference implementations above already handle the
multi-v1= case correctly. If you wrote a custom verifier that only
checks the first v1= entry, you'll need to update it before
rotating.
Local development¶
For local dev / staging you can register a webhook pointing at smee.io, ngrok, or localtunnel.me. last1.id's drainer is happy to POST to any HTTPS URL.
http:// URLs are accepted in non-production environments only.
In production, the drainer refuses to fire HTTP and dead-letters the
delivery with non_https_url_blocked_in_prod.
Operator notes (for last1.id team)¶
The webhook delivery outbox lives in webhook_deliveries. To inspect:
SELECT id, app_client_id, event_type, status, attempts,
last_response_status, last_error, created_at, delivered_at
FROM webhook_deliveries
WHERE app_client_id = '<uuid>'
ORDER BY created_at DESC
LIMIT 50;
To manually re-queue a dead-lettered delivery (rare; usually a bug fix is the right answer):
UPDATE webhook_deliveries
SET status = 'pending',
next_attempt_at = NOW(),
attempts = 0,
last_error = NULL,
dead_lettered_at = NULL
WHERE id = '<uuid>';
The drainer (server/services/webhookDrainer.ts) is gated by
WEBHOOK_DRAINER_ENABLED and ticks every 5 seconds by default. In
local dev:
Tail logs for webhook_drainer.* events to watch deliveries fire.