Developer platform
The same engine the app runs on, with your key in it
There is no integration tier bolted onto the side. The /v1 API, the event stream and the webhooks are the product: scoped test and live keys, idempotent writes and a signature you can verify in one line. This page mirrors the reference inside the app.
Conventions
Four rules, everywhere
Learn them once on the first route and they hold on every other one.
Scoped keys
pz_test_ and pz_live_ keys carry explicit scopes. A missing scope is a refusal with the scope named in the message.
Retry safely
Send an Idempotency-Key on any write and a repeat of the same request returns the first result instead of a second record.
Signed webhooks
HMAC SHA-256 over a timestamp and the raw body, with the replay window closed at five minutes.
One error shape
Every response carries a request_id. Every error carries a code, a human message and a doc_url.
Quickstart
Send, then watch it come back signed
One call sends the email. The delivery lands in the event stream and on your webhook endpoint, tied to the same contact. Request to response to event to webhook, one trail.
import { PopzIQ } from "@popziq/node";
const popz = new PopzIQ(process.env.POPZIQ_API_KEY);
await popz.email.send({
from: "hello@espresso.studio",
to: "mia@espresso.studio",
subject: "Welcome",
html: "<h1>You're in</h1>"
});POST https://api.you.dev/hooks
Popziq-Signature: t=1787172127,v1=9f2ac1…
{
"type": "email.delivered",
"contact": "ct_9m4k2",
"email": "em_7h2df",
"occurred_at": "2026-08-20T09:42:07Z"
}Event catalog
32 event types, stable on purpose
Every meaningful action is one row with a type from this list, nothing else. An unknown type is rejected at the boundary and rows are never edited or deleted, so your integrations never chase a renamed event.
| Type | Emitted when |
|---|---|
form.viewed | A published form renders through the embed or its link page. |
form.submitted | A submission passes validation and is stored atomically. |
form.published | A form version goes live. Published versions are immutable. |
contact.created | An email resolves to a person for the first time. |
contact.updated | Attributes or custom fields change on a contact. |
contact.deleted | A contact is deleted. A tombstone remains. |
contact.imported | A CSV row lands as a contact through the importer. |
consent.granted | A consent checkbox or API flag is recorded. |
consent.withdrawn | A person withdraws consent. Sends stop. |
webhook.delivered | Your endpoint acknowledged a delivery with a 2xx. |
webhook.failed | A delivery attempt failed. Retries are logged per attempt. |
api_key.created | A scoped key is created. The secret is shown once. |
export.completed | A workspace export finished and is ready to download. |
email.sent | A send is accepted by the send layer. |
email.delivered | The receiving server accepted the message. |
email.opened | The recipient opened the message. |
email.clicked | A link in the message was clicked. |
email.bounced | The message bounced, hard or soft. |
email.complained | The recipient marked the message as spam. |
email.unsubscribed | A one-click unsubscribe is honored. Consent is withdrawn in the same transaction. |
broadcast.scheduled | A broadcast is queued for a send time. |
broadcast.sent | A broadcast finished handing every recipient to the send layer. |
payment.completed | A paid invoice settled. amount_minor is integer minor units. |
lead.created | A contact enters a pipeline as a lead. |
lead.updated | Fields on a lead change. |
lead.stage_changed | A lead moves between stages. Stage history is read from these rows. |
lead.assigned | A lead changes owner. |
lead.won | A lead is marked won. |
lead.lost | A lead is marked lost. |
automation.started | A workflow run begins for a contact. |
automation.completed | A workflow run settles. |
automation.failed | A workflow run gives up. The reason is on the row. |
The table scrolls sideways on a narrow screen.
Reserved names
Inbound email is the one name held in reserve, so nothing has to be renamed when it arrives. It does not appear in any stream today.
email.received
The /v1 surface
Nine routes, one set of conventions
Bearer API keys, JSON envelopes with a request_id, cursor pagination with a limit of 100 and one error shape everywhere. Anything outside your key's workspace answers 404, never a 403 that leaks existence.
| Route | Scope | Summary |
|---|---|---|
GET/v1/forms | forms:read | List forms, newest first, cursor paginated. |
GET/v1/forms/:id | forms:read | One form with its published version and document. |
POST/v1/submissions | submissions:write | Create a submission. Validates, resolves the contact and records form.submitted atomically. Idempotency-Key honored. |
GET/v1/submissions | submissions:read | List submissions, newest first. |
POST/v1/contacts | contacts:write | Create or update by normalized email. Never duplicates. Idempotency-Key honored. |
GET/v1/contacts | contacts:read | List contacts, newest first. |
GET/v1/contacts/:id | contacts:read | One contact. Ids outside your workspace answer 404, never 403. |
PATCH/v1/contacts/:id | contacts:write | Update attributes and custom fields. Email is the identity key and is not patchable in v1. |
GET/v1/events | events:read | The append-only event stream, newest first. |
The table scrolls sideways on a narrow screen.
{
"error": {
"code": "missing_scope",
"message": "This key is missing the contacts:write scope.",
"doc_url": "https://popziq.com/docs/errors#missing_scope"
},
"request_id": "req_01J9W4…"
}Webhooks
Signed, retried and logged
Every delivery carries a Popziq-Signature header: an HMAC SHA-256 over a timestamp and the raw body. Reject stale timestamps even when the signature matches; replay windows close at five minutes.
Failed deliveries retry five times with exponential backoff and every attempt is logged with its request and response, so a 3am incident is a log read, not an archaeology dig.
import { verifyWebhookSignature } from "@popziq/node";
// rawBody is the exact request body string, before parsing.
const header = request.headers.get("Popziq-Signature");
const valid = await verifyWebhookSignature(secret, header, rawBody);
if (!valid) {
return new Response("invalid signature", { status: 400 });
}
// Without the package: the header is t=<unix>,v1=<hex>
// where v1 = HMAC_SHA256(secret, t + "." + rawBody).
// Compare in constant time. Reject anything older than 5 minutes.Embed
One script tag, under 30KB
The loader ships under 30KB gzipped with no dependencies, loads async and lazy-loads the renderer only when a form is about to show. Display mode is a property of the form, so inline, popup and slide-in are the same tag.
It always renders the currently published version. Publish in the builder and every site running the tag updates without a deploy.
<script src="https://popziq.com/e/<form_id>.js" async></script>window.popziq = {
open(formId), // open a popup or slide-in now
close(), // close the open form
on(event, cb), // "open", "close", "submitted"
}The full reference lives in the app
Request and response bodies for every route, payload schemas per event type and your own delivery logs, next to the keys that make the calls. Create a workspace and it is five minutes from signup to your first capture.