Notifications
Webhooks
NearlyIn sends your server a signed POST whenever something changes about an entry. Your system keeps up without polling the API.
Setting up
- In the dashboard, open “Webhooks” on your product's page.
- Pick the destination “Your own server”, enter a public https address, choose mode and events.
- Copy the secret. It is shown this one time only.
- Send a test event from the delivery log and check that your server answers with 2xx.
Events
entry.createdAn address signed up
Someone put their address on the list. It is pending until they confirm it by mail; unconfirmed entries are deleted after 14 days without a further event.
{
"id": "evt_3f9a1c0b7d2e4a6f8b1c2d3e",
"type": "entry.created",
"createdAt": "2026-10-11T09:14:02.110Z",
"mode": "live",
"productId": "cmgk3wq1a0000abcd5678ijkl",
"data": {
"entry": {
"id": "cmgk3x9f20001abcd1234efgh",
"email": "ada@example.com",
"state": "pending",
"createdAt": "2026-10-11T09:12:45.120Z",
"confirmedAt": null,
"invitedAt": null,
"redeemedAt": null,
"referralCode": "k4m9q2xh7d",
"referrals": 0
}
}
}entry.confirmedAn address was confirmed
The person pressed the button behind the link in the confirmation mail. The entry is now on the waitlist and can be invited.
{
"id": "evt_3f9a1c0b7d2e4a6f8b1c2d3e",
"type": "entry.confirmed",
"createdAt": "2026-10-11T09:14:02.110Z",
"mode": "live",
"productId": "cmgk3wq1a0000abcd5678ijkl",
"data": {
"entry": {
"id": "cmgk3x9f20001abcd1234efgh",
"email": "ada@example.com",
"state": "confirmed",
"createdAt": "2026-10-11T09:12:45.120Z",
"confirmedAt": "2026-10-11T09:14:02.004Z",
"invitedAt": null,
"redeemedAt": null,
"referralCode": "k4m9q2xh7d",
"referrals": 0
}
}
}entry.invitedAn entry was invited
The invitation mail was sent, from the dashboard or through the API. The event does not contain the personal code: that only ever travels in the mail.
{
"id": "evt_3f9a1c0b7d2e4a6f8b1c2d3e",
"type": "entry.invited",
"createdAt": "2026-10-11T09:14:02.110Z",
"mode": "live",
"productId": "cmgk3wq1a0000abcd5678ijkl",
"data": {
"entry": {
"id": "cmgk3x9f20001abcd1234efgh",
"email": "ada@example.com",
"state": "invited",
"createdAt": "2026-10-11T09:12:45.120Z",
"confirmedAt": "2026-10-11T09:14:02.004Z",
"invitedAt": "2026-10-12T08:00:00.000Z",
"redeemedAt": null,
"referralCode": "k4m9q2xh7d",
"referrals": 0
}
}
}entry.redeemedAn invitation was redeemed
Your server redeemed the personal code of an invitation (POST /api/v1/invites/redeem): the person has come in. Sent once per entry.
{
"id": "evt_5b2c9e1f0a3d4c7b8e6f1a2b",
"type": "entry.redeemed",
"createdAt": "2026-10-12T08:31:10.412Z",
"mode": "live",
"productId": "cmgk3wq1a0000abcd5678ijkl",
"data": {
"entry": {
"id": "cmgk3x9f20001abcd1234efgh",
"email": "ada@example.com",
"state": "redeemed",
"createdAt": "2026-10-11T09:12:45.120Z",
"confirmedAt": "2026-10-11T09:14:02.004Z",
"invitedAt": "2026-10-12T08:00:00.000Z",
"redeemedAt": "2026-10-12T08:31:10.398Z",
"referralCode": "k4m9q2xh7d",
"referrals": 0
}
}
}entry.unsubscribedA person removed themselves
The person used the unsubscribe link of a mail. The entry is deleted; data.entry shows it as it was. Remove the address on your side as well.
{
"id": "evt_3f9a1c0b7d2e4a6f8b1c2d3e",
"type": "entry.unsubscribed",
"createdAt": "2026-10-11T09:14:02.110Z",
"mode": "live",
"productId": "cmgk3wq1a0000abcd5678ijkl",
"data": {
"entry": {
"id": "cmgk3x9f20001abcd1234efgh",
"email": "ada@example.com",
"state": "invited",
"createdAt": "2026-10-11T09:12:45.120Z",
"confirmedAt": "2026-10-11T09:14:02.004Z",
"invitedAt": "2026-10-12T08:00:00.000Z",
"redeemedAt": null,
"referralCode": "k4m9q2xh7d",
"referrals": 0
}
}
}entry.deletedAn entry was deleted by your team
Someone of your team deleted the entry in the dashboard, or your backend did with DELETE /api/v1/entries/{id}. data.entry shows it as it was.
{
"id": "evt_3f9a1c0b7d2e4a6f8b1c2d3e",
"type": "entry.deleted",
"createdAt": "2026-10-11T09:14:02.110Z",
"mode": "live",
"productId": "cmgk3wq1a0000abcd5678ijkl",
"data": {
"entry": {
"id": "cmgk3x9f20001abcd1234efgh",
"email": "ada@example.com",
"state": "invited",
"createdAt": "2026-10-11T09:12:45.120Z",
"confirmedAt": "2026-10-11T09:14:02.004Z",
"invitedAt": "2026-10-12T08:00:00.000Z",
"redeemedAt": null,
"referralCode": "k4m9q2xh7d",
"referrals": 0
}
}
}Checking the signature
Every call carries the header NearlyIn-Signature: an HMAC-SHA256 over timestamp and body with the endpoint's secret. Check it before you act on anything. With the package that is one line:
webhook.ts
// Receiving NearlyIn's webhooks: any runtime with the Fetch API (Next.js route, Hono, Deno, Workers).
import { WebhookVerificationError, verifyWebhook } from "nearlyin/webhooks";
/** POST /webhooks/nearlyin */
export async function onNearlyInWebhook(request: Request): Promise<Response> {
// The signature is over the body exactly as sent: read it as text, do not parse it first.
const payload = await request.text();
try {
const event = await verifyWebhook(
payload,
request.headers.get("NearlyIn-Signature"),
process.env.NEARLYIN_WEBHOOK_SECRET!,
);
// A delivery may arrive more than once. `event.id` is the same each time: use it to skip repeats.
if (event.type === "entry.confirmed") {
console.log(`${event.data.entry.email} is on the waitlist.`);
}
// Answer fast with 2xx and do slow work afterwards; anything else is retried for about ten hours.
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}NearlyIn-Signatureheader · string · requiredt=<unix seconds>,v1=<hex>. The hex is HMAC-SHA256 over<t>.<body>with the endpoint's signing secret (whsec_…). Verify it withverifyWebhookfromnearlyin/webhooks, or by hand: recompute the HMAC over the raw body, compare in constant time, and refuse timestamps older than five minutes.NearlyIn-Eventheader · string · requiredThe event type, the same as
typein the body.NearlyIn-Deliveryheader · string · requiredId of this delivery. Stays the same when the delivery is retried.
Retries
Only a 2xx answer within 5 seconds counts. Otherwise NearlyIn tries again up to five times, after 1, 5, 30, 120 and 480 minutes. Every attempt carries the same event id and a fresh signature, so you recognise repeats.
The delivery log shows the last 100 deliveries per endpoint, for 30 days. We delete an event's body as soon as it has been delivered or given up.
Fields
Fields of an event
idstring · requiredId of the event. The same for every attempt to deliver it: use it to recognise repeats.
type"entry.created" | "entry.confirmed" | "entry.invited" | "entry.redeemed" | "entry.unsubscribed" | "entry.deleted" | "webhook.test" · requiredwebhook.testis only sent when you press “Send test event” in the dashboard. It goes to the endpoint whatever events it listens for, and itsdata.entryis made up.createdAtstring · requiredWhen it happened.
mode"live" | "test" · requiredLive endpoints only get live events, test endpoints only test events.
productIdstring · requireddataobject · required
Fields of an entry
idstring · requiredStable id of the entry.
emailstring · requiredTrimmed and lowercased.
state"pending" | "confirmed" | "invited" | "redeemed" · requiredpending: signed up, address not confirmed yet.confirmed: on the waitlist.invited: the invitation mail was sent.redeemed: your server redeemed the personal code; the person has come in.createdAtstring · requiredWhen the address signed up (or last asked for a new confirmation mail).
confirmedAtstring | null · requiredWhen the address was confirmed.
invitedAtstring | null · requiredWhen the entry was invited for the first time. Sending the invitation again does not change it.
redeemedAtstring | null · requiredWhen your server redeemed the personal code of the invitation: the person has come in.
referralCodestring | null · requiredCode of this entry's referral link. Append
?ref=<code>to the address of a page with the form: sign-ups through it that confirm move this entry up the line.referralsinteger · requiredSign-ups through this entry's referral link that confirmed.