Dunnhawk Pro · API 2026-09-22

Lockout API & webhooks

Let your app ask Dunnhawk one question, should this subscriber still get in?, and get pushed a signed event whenever the answer changes. Dunnhawk only says “locked” after the reminder emails have run and the payment is still failing.

Overview

  • Base URL: https://dunnhawk.com/api/v1. JSON in, JSON out, HTTPS only.
  • Server-to-server only. Never put an API key in a browser or mobile app.
  • Subscribers are identified by their Stripe customer id (cus_...) on your connected account, or by email.
  • Available on the Pro plan (and early-access accounts). Keys and webhooks live under Portal → Developers.

Authentication

Create a key in Developers and send it as a bearer token. Keys look like dh_live_... (or dh_test_... while Dunnhawk runs on Stripe test mode). We store only a hash, so a key is shown once; revoke and replace it if it leaks.

curl https://dunnhawk.com/api/v1/access?customer=cus_123 \
  -H "Authorization: Bearer $DUNNHAWK_API_KEY"

Check access

GET /api/v1/access?customer=cus_123 or GET /api/v1/access?email=sam@northwind.io. The path form GET /api/v1/subscribers/cus_123/access works too. A subscriber Dunnhawk has never seen fail a payment is active with known: false, so you can call it for everyone.

{
  "object": "subscriber_access",
  "customer": "cus_123",
  "email": "sam@northwind.io",
  "status": "past_due",          // active | past_due | locked | canceled
  "locked": false,
  "reason": "payment_failed",
  "since": "2026-09-02T14:10:00.000Z",
  "lock_at": "2026-09-16T14:10:00.000Z",
  "amount_due": 4900,
  "currency": "usd",
  "invoice": "in_1Pq...",
  "open_invoices": 1,
  "update_url": "https://dunnhawk.com/u/clx...Ab3",
  "known": true
}

The access object

FieldMeaning
statusactive · past_due (recovery in progress, show a banner) · locked (gate access) · canceled (subscription deleted while unpaid)
lockedThe one boolean to act on. Always false unless you enabled lockout.
reasonpayment_failed · payment_failed_lockout · subscription_canceled · null
sinceWhen the current state began (ISO 8601).
lock_atWhen a past_due subscriber will be locked if still unpaid (lockout on). Great for “update by Sept 16” banners.
amount_due, currencyUnpaid amount in the smallest currency unit (e.g. cents).
invoiceThe Stripe invoice id driving this state.
update_urlA link to a Stripe-hosted card update (created fresh on click). Only present when past_due or locked.
knownfalse when Dunnhawk has no failed payments on record for this subscriber.

How lockout works

  1. A renewal fails. The subscriber becomes past_due and Dunnhawk's reminders start (day 0, 3, 7, 14).
  2. If still unpaid after your lockout window (3, 7, 14, 21, 30 days; default 14, right after the last reminder), they become locked and a subscriber.locked webhook fires.
  3. The moment they pay, they're active again and subscriber.recovered fires with unlocked: true.
  4. A later successful payment (e.g. they resubscribed) always clears older unpaid invoices, so a paying customer is never stuck.
  5. Payments held out for Proof of Lift are never locked or reported, to keep the experiment clean.

Lockout is off until you enable it in Developers. With it off, the API still reports past_due for banners.

// middleware.ts (Next.js) — gate your app when Dunnhawk says "locked".
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(req: NextRequest) {
  const stripeCustomerId = req.cookies.get("stripe_customer_id")?.value; // however you store it
  if (!stripeCustomerId) return NextResponse.next();

  try {
    const res = await fetch(
      `https://dunnhawk.com/api/v1/access?customer=${stripeCustomerId}`,
      {
        headers: { Authorization: `Bearer ${process.env.DUNNHAWK_API_KEY}` },
        next: { revalidate: 60 }, // cache for a minute
      }
    );
    const access = await res.json();
    if (access.locked) {
      const url = new URL("/billing-locked", req.url);
      url.searchParams.set("update", access.update_url);
      return NextResponse.redirect(url);
    }
  } catch {
    // Fail OPEN: never lock a paying user out because of a network blip.
  }
  return NextResponse.next();
}

export const config = { matcher: ["/app/:path*"] };
// Node / Express: check once per session and cache the answer.
async function dunnhawkAccess(stripeCustomerId) {
  const res = await fetch(`https://dunnhawk.com/api/v1/access?customer=${stripeCustomerId}`, {
    headers: { Authorization: `Bearer ${process.env.DUNNHAWK_API_KEY}` },
  });
  if (!res.ok) return { locked: false }; // fail open
  return res.json();
}

app.use(async (req, res, next) => {
  const access = await dunnhawkAccess(req.user.stripeCustomerId);
  if (access.locked) return res.redirect(access.update_url);
  res.locals.pastDue = access.status === "past_due"; // show a banner
  next();
});

List subscribers

GET /api/v1/subscribers?status=locked&limit=50 returns everyone who isn't active (optionally filtered by past_due, locked or canceled). Paginate with starting_after=<customer id> while has_more is true. Useful for a nightly sync instead of per-request checks.

{ "object": "list", "data": [ { "object": "subscriber_access", ... } ], "has_more": false }

Test your key

GET /api/v1/ping returns your account name, plan and lockout settings.

Webhooks

Add an HTTPS endpoint in Developers and pick events. Each event's data.object is the same access object as the API, so your handler can simply mirror status.

  • subscriber.past_due: A renewal failed and recovery started. Show an “update your card” banner.
  • subscriber.locked: Still unpaid after your lockout window. Gate access until they update their card.
  • subscriber.recovered: They paid. Remove banners and restore access.
  • subscriber.canceled: The subscription was deleted while unpaid.
  • test.ping: sent when you click “Send test event”.
{
  "id": "evt_4f1c2a9b8e7d6c5b4a39281f",
  "object": "event",
  "type": "subscriber.locked",
  "api_version": "2026-09-22",
  "created": "2026-09-16T15:17:02.114Z",
  "livemode": true,
  "data": {
    "object": {
      "object": "subscriber_access",
      "customer": "cus_123",
      "email": "sam@northwind.io",
      "status": "locked",
      "locked": true,
      "reason": "payment_failed_lockout",
      "since": "2026-09-16T15:17:00.000Z",
      "lock_at": null,
      "amount_due": 4900,
      "currency": "usd",
      "invoice": "in_1Pq...",
      "open_invoices": 1,
      "update_url": "https://dunnhawk.com/u/clx...Ab3",
      "known": true
    }
  }
}

Headers: Dunnhawk-Signature, Dunnhawk-Event-Id, Dunnhawk-Event-Type, Dunnhawk-Delivery-Attempt.

Verify signatures

Dunnhawk-Signature: t=1790000000,v1=<hex> where v1 is HMAC-SHA256 of `${t}.${rawBody}` with your endpoint's signing secret. Compare in constant time and reject timestamps older than 5 minutes. Always use the raw body, before JSON parsing.

// Verify Dunnhawk webhooks (Node 18+). Use the RAW request body.
import crypto from "node:crypto";

export function verifyDunnhawk(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=").map((s) => s.trim()))
  );
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`, "utf8")
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Next.js route handler: app/api/webhooks/dunnhawk/route.ts
export async function POST(req) {
  const raw = await req.text();
  if (!verifyDunnhawk(raw, req.headers.get("dunnhawk-signature") ?? "", process.env.DUNNHAWK_WEBHOOK_SECRET)) {
    return new Response("bad signature", { status: 400 });
  }
  const event = JSON.parse(raw);
  const access = event.data.object; // same shape as GET /api/v1/access
  switch (event.type) {
    case "subscriber.locked":    await lockUser(access.customer, access.update_url); break;
    case "subscriber.recovered": await unlockUser(access.customer); break;
    case "subscriber.past_due":  await showBanner(access.customer, access.lock_at); break;
    case "subscriber.canceled":  await markCanceled(access.customer); break;
  }
  return new Response("ok"); // any 2xx within 8s = delivered
}
# Verify Dunnhawk webhooks (Python 3)
import hmac, hashlib, time

def verify_dunnhawk(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Retries & delivery

  • Any 2xx within 8 seconds counts as delivered. Redirects are not followed.
  • Failed deliveries retry 7 times: 1 min, 5 min, 30 min, 2 h, 5 h, 10 h, 24 h (about 2 days total).
  • Events are at-least-once: the same Dunnhawk-Event-Id may arrive twice. Make handlers idempotent (store processed ids, or just apply the status).
  • Order isn't guaranteed; if in doubt, call GET /api/v1/access for the current truth.
  • An endpoint that fails 40 attempts in a row is disabled and you get an email.
  • Endpoints must be public HTTPS URLs; private and internal addresses are refused.

Update popup (embed.js)

One script tag adds a secure “update your card” popup to any site. It shows Stripe's own card form (running on your connected Stripe account), makes the new card the default for the customer and their subscriptions, and immediately pays whatever is overdue, oldest first. 3-D Secure is handled inside the popup. Card details go straight to Stripe; neither your servers nor Dunnhawk's ever see them. Turn it on in Developers → Update popup to get your data-key.

<!-- 1. Once, anywhere on the page (e.g. before </body>) -->
<script src="https://dunnhawk.com/embed.js" data-key="dhpk_your_public_key" async></script>

<!-- 2. Any button or link that should open the popup -->
<button data-dunnhawk="update-card">Update card</button>

Without a token, the popup asks for the subscriber's email and sends a secure link (valid 24 hours) to that address, only if it has an open payment with you. This works on no-code sites. The answer on screen is the same either way, so the popup can't be used to find out who your customers are.

  • Any element with data-dunnhawk="update-card" opens the popup.
  • data-banner="off" hides the automatic banner; data-banner-position="top" moves it.
  • JavaScript: Dunnhawk.open(), Dunnhawk.close(), Dunnhawk.status(), Dunnhawk.setToken(t), Dunnhawk.on("success" | "close" | "open" | "link_sent", fn).
  • Everything is drawn in a Shadow DOM, so your CSS can't break it and it can't break yours.
  • Limit which sites may show it under “Sites allowed” (enforced with CSP frame-ancestors).
// Optional: react in your app after a successful update.
window.Dunnhawk.on("success", (e) => {
  // e.paid: true if an overdue payment was collected just now
  location.reload(); // e.g. refresh access
});

// Or open it from your own code:
window.Dunnhawk.open();

Popup sessions (signed-in users)

If you know who is signed in, mint a session on your server and pass its token to the script tag. The popup then opens the card form directly, and a small “your payment didn't go through” banner appears by itself while that user is past due or locked. Sessions last 2 hours; create one per page view.

curl -X POST https://dunnhawk.com/api/v1/embed/sessions \
  -H "Authorization: Bearer $DUNNHAWK_API_KEY" \
  -d customer=cus_123
{
  "object": "embed_session",
  "token": "dhes_…",
  "url": "https://dunnhawk.com/embed/frame?t=dhes_…",
  "expires_at": "2026-09-23T16:00:00.000Z",
  "customer": "cus_123",
  "status": "past_due",
  "locked": false
}

Parameters: customer (Stripe customer id) or email (a subscriber Dunnhawk already tracks). The url opens the same form full-page, handy for your own emails or in-app links. Errors: 409 embed_disabled (turn the popup on first), 409 stripe_not_connected, 404 resource_missing (unknown email).

// Server (Node / Next.js): mint a short-lived popup session for the signed-in user.
// Never expose your API key to the browser: do this on the server.
async function dunnhawkPopupToken(stripeCustomerId) {
  const res = await fetch("https://dunnhawk.com/api/v1/embed/sessions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.DUNNHAWK_API_KEY}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({ customer: stripeCustomerId }),
  });
  if (!res.ok) return null; // fail open: the page still works without it
  return (await res.json()).token; // valid for 2 hours
}

// Page template: add the token to the same script tag.
// <script src="https://dunnhawk.com/embed.js" data-key="dhpk_your_public_key" data-token="{{ token }}" async></script>
//
// With a token the button opens the card form directly (no email step), and
// a small "Your payment didn't go through" banner appears on its own while
// the user is past due or locked. Turn the banner off with data-banner="off".

Abuse protection: each session allows 6 card attempts, with a daily cap per subscriber and per IP, to stop card testing. Stripe Radar runs on every card as usual.

Errors

{ "error": { "type": "authentication_error", "message": "...", "docs": "https://dunnhawk.com/docs/api#errors" } }
  • 400 invalid_request_error: missing or malformed parameters.
  • 401 authentication_error: missing, malformed or revoked key.
  • 403 plan_error: your plan doesn't include the API (upgrade to Pro).
  • 429 rate_limit_error: slow down; see Retry-After.
  • 5xx: our problem. Fail open and retry later.

Rate limits

600 requests per minute per key. Cache results for about 60 seconds per subscriber, or use webhooks and skip polling entirely.

Best practices

  • Fail open. If Dunnhawk is unreachable, let the user in. Never lock a paying customer out over a timeout.
  • Warn before you lock. Show a banner with lock_at while past_due.
  • Make the fix one click. Send locked users straight to update_url; set a return URL in Developers so they land back in your app.
  • Keep read-only access (e.g. exporting their data) even when locked. It's kinder and reduces chargebacks.

Questions or need a higher limit? Email ismail@dunnhawk.com.