Skip to content

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:

  1. HTTPS endpoint URL (e.g. https://last.vet/api/webhooks/last1).
  2. Acknowledgement that you understand the security model below.
  3. Confirmation that you want webhooks enabled (webhook_enabled defaults to false so 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:

HMAC-SHA256(secret, "<unix_timestamp>.<raw_request_body>")

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-Delivery so 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:

  1. Tell your operator at last1.id you want to rotate.
  2. 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>.
  3. Update your partner config to the new secret.
  4. 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:

WEBHOOK_DRAINER_ENABLED=true LOG_LEVEL=debug npm run dev

Tail logs for webhook_drainer.* events to watch deliveries fire.