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
37 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 hosted link. |
form.started | A visitor touches a field for the first time on a view. Deduped per view, so a start is one event. |
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. |
form.payment_received | Somebody paid you through a form. Not the same money as payment.completed above: that one is your subscription to us, this one is a customer paying you on your own Stripe account. |
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. |
page.viewed | A link page is opened. It names no person and never carries a contact id.Not emitted. Publishing a link page is not part of the product, so no workspace stream carries this type. |
page.clicked | A link on a link page is pressed. The destination is recorded as a host rather than a full address. The row names no person.Not emitted. Publishing a link page is not part of the product, so no workspace stream carries this type. |
page.published | A link page goes live at its address.Not emitted. Publishing a link page is not part of the product, so no workspace stream carries this type. |
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 if it is added. It does not appear in any stream today.
email.received
The /v1 surface
Twelve /v1 routes, plus one signed download link
Thirteen in all. Twelve of them sit under /v1. The other one, the signed download link a form's file is fetched with, does not, because the URL it mints is handed to a browser rather than to a client library.
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. The reference below is generated from the same schemas the worker validates with, so it cannot drift from what the API accepts.
| 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/submissions/:submissionId/pdf | submissions:read | A branded PDF of one submission, with the signature drawn where the form took one and a certificate of completion when it was signed. Built on request and never stored. |
GET/v1/events | events:read | The append-only event stream, newest first. |
POST/v1/emails | email:send | Send one message to one contact of the workspace. The recipient is a contact id rather than an address, because suppression and consent belong to a person. A suppressed contact is refused for every kind. Marketing also needs granted consent, carries an unsubscribe link and counts against the plan allowance. Idempotency-Key honored. |
POST/v1/emails/batch | email:send | Send up to 100 messages under the same rules as POST /v1/emails. Partial failure is reported rather than raised: every message gets a result with its index and either a message id or an error. 200 means all of them left and 207 means at least one did not. Idempotency-Key honored. |
GET/uploads/:id/link | submissions:read | A five-minute signed download link for a file a form collected. Ids outside your workspace answer 404, never 403. Files are always served as an attachment, never as their own type. |
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, slide-in, bar and fullscreen 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, slide-in, bar or fullscreen form now
close(), // close the open form
on(event, cb), // "open", "close", "submit", "unavailable", "blocked"
}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.