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
| Field | Meaning |
|---|---|
status | active · past_due (recovery in progress, show a banner) · locked (gate access) · canceled (subscription deleted while unpaid) |
locked | The one boolean to act on. Always false unless you enabled lockout. |
reason | payment_failed · payment_failed_lockout · subscription_canceled · null |
since | When the current state began (ISO 8601). |
lock_at | When a past_due subscriber will be locked if still unpaid (lockout on). Great for “update by Sept 16” banners. |
amount_due, currency | Unpaid amount in the smallest currency unit (e.g. cents). |
invoice | The Stripe invoice id driving this state. |
update_url | A link to a Stripe-hosted card update (created fresh on click). Only present when past_due or locked. |
known | false when Dunnhawk has no failed payments on record for this subscriber. |
How lockout works
- A renewal fails. The subscriber becomes
past_dueand Dunnhawk's reminders start (day 0, 3, 7, 14). - If still unpaid after your lockout window (3, 7, 14, 21, 30 days; default 14, right after the last reminder), they become
lockedand asubscriber.lockedwebhook fires. - The moment they pay, they're
activeagain andsubscriber.recoveredfires withunlocked: true. - A later successful payment (e.g. they resubscribed) always clears older unpaid invoices, so a paying customer is never stuck.
- 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-Idmay arrive twice. Make handlers idempotent (store processed ids, or just apply thestatus). - Order isn't guaranteed; if in doubt, call
GET /api/v1/accessfor 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; seeRetry-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_atwhilepast_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.