For founders

Connect Day Zero to your product

Backers press Activate in their Day Zero account and land in your app with their lifetime plan. Two endpoints, the same shape as AppSumo Licensing v2.

Paste this into your coding AI

Copy the prompt into Cursor, Claude, or ChatGPT in your product repo. It adds a webhook and a redirect URL, matching whatever stack you already ship. Your keys are on /dashboard/integration. Put them in .env, not in the chat.

Add Day Zero lifetime-deal licensing to THIS product. Match the repo's language, framework, auth and database. Don't rewrite the app.

Day Zero works exactly like AppSumo Licensing v2 (webhooks + OAuth). If this repo already has an AppSumo integration, reuse it: only the URLs and header names differ.

Env vars (already set by the founder, never hardcode or commit them):
DAYZERO_CLIENT_ID, DAYZERO_CLIENT_SECRET, DAYZERO_API_KEY, DAYZERO_ORIGIN=https://dayzero.ltd

## 1. Webhook endpoint: POST https://<your-app>/api/dayzero/webhook

- Read the RAW body. Verify: hex HMAC-SHA256 of (X-DayZero-Timestamp header + raw body) keyed with DAYZERO_API_KEY must equal the X-DayZero-Signature header (timing-safe compare). Otherwise answer 401.
- Body: {"license_key": uuid, "event": "purchase" | "activate" | "deactivate" | "upgrade" | "downgrade" | "migrate", "license_status": status BEFORE this event, "event_timestamp": ms, "created_at": ms, "tier": 1, "test": bool, "extra": {"reason": string}} (upgrade/downgrade also send "prev_license_key").
- Store licenses in a table keyed by license_key (status: inactive | active | deactivated, plus the user it belongs to). Handlers must be idempotent: the same event can arrive twice.
  - purchase: upsert as inactive.
  - activate: upsert as active. The customer is about to arrive at the Redirect URL.
  - deactivate: mark deactivated and remove the lifetime plan from its user (this is a refund).
  - upgrade/downgrade/migrate: not used yet; move the plan from prev_license_key to license_key if you handle them.
- "test": true events: don't touch the database, just acknowledge.
- Always answer HTTP 200 with JSON {"success": true, "event": "<the event you received>"}. Anything else counts as a failure and is retried.

## 2. Redirect URL: GET https://<your-app>/dayzero/callback

- Without ?code= it must return 200 (Day Zero checks it with a plain GET). Show a short "Activate from your Day Zero account" message.
- With ?code=: server-side only, POST https://dayzero.ltd/openid/token (JSON or form) with grant_type=authorization_code, code, client_id, client_secret, redirect_uri=https://<your-app>/dayzero/callback (exactly). Response: {access_token, token_type, expires_in, refresh_token, id_token}. Codes work once and expire after 10 minutes.
- Then GET https://dayzero.ltd/openid/license_key?access_token=<access_token> → {"license_key", "status", "scopes"}. Only continue if status is "active".
- Signed-in user: attach the license_key to their account and grant the lifetime plan, then send them into the app.
- Signed out: keep license_key in a short-lived httpOnly cookie, send them to sign up (or sign in), attach it right after, and grant the plan.
- One license_key belongs to one account. If it's already attached to a different account, show an error.

## Done when

- Both routes deploy on the public https:// site.
- In the Day Zero wizard (https://dayzero.ltd/dashboard/integration), saving both URLs shows green checks, and "Run test activation" lands on the Redirect URL, which grants the plan.

Reference: https://dayzero.ltd/resources/integrate

How it works

  1. Open /dashboard/integration. Your client ID, client secret and API key are already there. Copy them into your app's env vars.
  2. Add a Webhook URL (POST) and a Redirect URL (GET) to your app, then save both in the wizard. We check them on the spot: a GET to the Redirect URL must return 200, and your webhook must acknowledge test activate and deactivate events.
  3. Press Run test activation. We send purchase and activate for a test license, then open your Redirect URL with ?code=. Your server swaps the code at /openid/token and reads the license at /openid/license_key. The step ticks off.
  4. Go live. Backers get an Activate button. Activating sends you activate (you must acknowledge it), then sends the backer to your Redirect URL. A refund sends deactivate.

Webhooks

We POST JSON to your Webhook URL with two headers: X-DayZero-Timestamp (ms) and X-DayZero-Signature, the hex HMAC-SHA256 of the timestamp followed by the raw body, keyed with your API key. Compare it in constant time.

{
  "license_key": "0b8a3c5e-6f41-4d8e-9a3c-2b1f7e5d4c90",
  "event": "activate",
  "license_status": "inactive",
  "event_timestamp": 1760000000000,
  "created_at": 1759990000000,
  "tier": 1,
  "test": false,
  "extra": { "reason": "Activated by the customer" }
}

license_status is the status before the event. event_timestamp changes on every retry; created_at is when the license key was made. Validation events have "test": true and a placeholder key: acknowledge them without touching your data.

eventlicense_statusWhat to do
purchaseinactiveA backer bought a seat. Optional to handle.
activateinactiveThe backer pressed Activate and is on the way to your Redirect URL. Mark the license active.
deactivateactive or inactiveRefund. Take the lifetime plan away.
upgrade / downgradeactiveNot sent yet (one tier). Carries prev_license_key.
migrateactiveNot sent yet.

Answer HTTP 200 with {"success": true, "event": "<event>"}. Anything else is a failure. Failed events retry after 1, 5 and 30 minutes, 2 and 12 hours, then a day; after that we email you and your contacts. activate is different: the backer waits on it, so if you don't acknowledge it the activation doesn't happen and they can try again. Redirects aren't followed, so save the final URL.

app/api/dayzero/webhook/route.ts

// app/api/dayzero/webhook/route.ts — your Webhook URL
import crypto from "node:crypto";

export async function POST(request: Request) {
  const body = await request.text(); // raw body: sign exactly what we sent
  const timestamp = request.headers.get("x-dayzero-timestamp") ?? "";
  const signature = request.headers.get("x-dayzero-signature") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.DAYZERO_API_KEY!)
    .update(timestamp + body)
    .digest("hex");
  if (
    signature.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    return Response.json({ success: false }, { status: 401 });
  }

  const event = JSON.parse(body);
  if (!event.test) {
    switch (event.event) {
      case "purchase": // sold; not used yet
        await db.license.upsert(event.license_key, { status: "inactive" });
        break;
      case "activate": // the backer is on their way to your Redirect URL
        await db.license.upsert(event.license_key, { status: "active" });
        break;
      case "deactivate": // refunded: take the lifetime plan away
        await db.license.deactivate(event.license_key);
        break;
    }
  }
  // Day Zero only counts the event as delivered with this exact answer.
  return Response.json({ success: true, event: event.event });
}

Redirect URL (OAuth)

After a backer activates, we send them to your Redirect URL with a one-time code. Codes expire after 10 minutes and are used up by the first attempt, right or wrong. Swap it on your server (JSON or form body; client credentials may also go in a Basic auth header):

POST /openid/token
Content-Type: application/json

{
  "grant_type": "authorization_code",
  "code": "<code from your Redirect URL>",
  "client_id": "<DAYZERO_CLIENT_ID>",
  "client_secret": "<DAYZERO_CLIENT_SECRET>",
  "redirect_uri": "<your Redirect URL, exactly as saved>"
}

200 OK
{
  "access_token": "…",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "…",
  "id_token": "…",
  "error": ""
}

GET /openid/license_key?access_token=…

200 OK
{ "license_key": "0b8a3c5e-…", "status": "active", "scopes": ["read_license"] }

/openid/license_key also accepts Authorization: Bearer. Errors: 403 for a bad, used or expired code, wrong client credentials or a redirect_uri that doesn't match; 401 for an expired access token (they last an hour). Get a new one with grant_type=refresh_token; refresh tokens rotate. Only active licenses should unlock the plan, and one license belongs to one account.

app/dayzero/callback/route.ts

// app/dayzero/callback/route.ts — your Redirect URL
const DAYZERO = process.env.DAYZERO_ORIGIN ?? "https://dayzero.ltd";
const REDIRECT_URL = "https://app.yourproduct.com/dayzero/callback"; // as saved on Day Zero

export async function GET(request: Request) {
  const code = new URL(request.url).searchParams.get("code");
  // Day Zero checks this URL with a plain GET, so answer 200 without a code.
  if (!code) return new Response("Activate your LTD from your Day Zero account.");

  const tokenResponse = await fetch(`${DAYZERO}/openid/token`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      client_id: process.env.DAYZERO_CLIENT_ID,
      client_secret: process.env.DAYZERO_CLIENT_SECRET,
      redirect_uri: REDIRECT_URL,
    }),
  });
  if (!tokenResponse.ok) return new Response("This link expired. Activate again from Day Zero.", { status: 400 });
  const { access_token } = await tokenResponse.json();

  const licenseResponse = await fetch(
    `${DAYZERO}/openid/license_key?access_token=${access_token}`
  );
  const { license_key, status } = await licenseResponse.json();
  if (status !== "active") return new Response("This license isn't active.", { status: 403 });

  // Signed in? Give this account the lifetime plan now. Otherwise keep the
  // key in a short-lived cookie and attach it right after sign-up.
  return new Response(null, {
    status: 302,
    headers: {
      location: "/signup",
      "set-cookie": `dayzero_license=${license_key}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`,
    },
  });
}

Licensing API

Base URL https://dayzero.ltd/api/licensing/v2. Send your API key in X-DayZero-Licensing-Key. 60 requests a minute. Lists return {"size", "items"}; pages start at 1. A license looks like {license_key, license_redemption_url, license_change_plan_url, status, tier, created_at, updated_at}.

  • GET /licenses — Sold and refunded licenses (?status=, ?page=, ?limit= up to 100)
  • GET /licenses/:license_key — One license
  • GET /licenses/events — Every license event, newest first
  • GET /licenses/:license_key/events — Events for one license
  • GET /licenses/:license_key/webhook-responses — What we sent and what your server answered
  • GET /profile — Your Redirect URL, Webhook URL and contacts
  • PUT /profile — Change the URLs (checked like the dashboard does)
  • POST /profile/contact — Add a contact we email when webhooks keep failing
  • DELETE /profile/contact/:id — Remove a contact

Coming from AppSumo?

Payloads, signatures, OAuth and API shapes are the same. Swap the names:

  • X-Appsumo-Signature → X-DayZero-Signature
  • X-Appsumo-Timestamp → X-DayZero-Timestamp
  • X-AppSumo-Licensing-Key → X-DayZero-Licensing-Key
  • https://appsumo.com/openid/token/ → https://dayzero.ltd/openid/token
  • https://appsumo.com/openid/license_key/ → https://dayzero.ltd/openid/license_key
  • https://api.licensing.appsumo.com/v2/ → https://dayzero.ltd/api/licensing/v2/

Essential cookies keep you signed in and remember your role. Optional analytics cookies (PostHog) show us how the site is used. Privacy