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.

pz_test_ and pz_live_ keysIdempotency-Key on writesCursor paginationrequest_id on every response

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.

send-welcome.ts
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.

Live event types and when each is emitted
TypeEmitted when
form.viewedA published form renders through the embed or its hosted link.
form.startedA visitor touches a field for the first time on a view. Deduped per view, so a start is one event.
form.submittedA submission passes validation and is stored atomically.
form.publishedA form version goes live. Published versions are immutable.
contact.createdAn email resolves to a person for the first time.
contact.updatedAttributes or custom fields change on a contact.
contact.deletedA contact is deleted. A tombstone remains.
contact.importedA CSV row lands as a contact through the importer.
consent.grantedA consent checkbox or API flag is recorded.
consent.withdrawnA person withdraws consent. Sends stop.
webhook.deliveredYour endpoint acknowledged a delivery with a 2xx.
webhook.failedA delivery attempt failed. Retries are logged per attempt.
api_key.createdA scoped key is created. The secret is shown once.
export.completedA workspace export finished and is ready to download.
email.sentA send is accepted by the send layer.
email.deliveredThe receiving server accepted the message.
email.openedThe recipient opened the message.
email.clickedA link in the message was clicked.
email.bouncedThe message bounced, hard or soft.
email.complainedThe recipient marked the message as spam.
email.unsubscribedA one-click unsubscribe is honored. Consent is withdrawn in the same transaction.
broadcast.scheduledA broadcast is queued for a send time.
broadcast.sentA broadcast finished handing every recipient to the send layer.
payment.completedA paid invoice settled. amount_minor is integer minor units.
form.payment_receivedSomebody 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.createdA contact enters a pipeline as a lead.
lead.updatedFields on a lead change.
lead.stage_changedA lead moves between stages. Stage history is read from these rows.
lead.assignedA lead changes owner.
lead.wonA lead is marked won.
lead.lostA lead is marked lost.
automation.startedA workflow run begins for a contact.
automation.completedA workflow run settles.
automation.failedA workflow run gives up. The reason is on the row.
page.viewedA 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.clickedA 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.publishedA 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.

Live routes an API key can call, with the scope each one needs
RouteScopeSummary
GET/v1/formsforms:readList forms, newest first, cursor paginated.
GET/v1/forms/:idforms:readOne form with its published version and document.
POST/v1/submissionssubmissions:writeCreate a submission. Validates, resolves the contact and records form.submitted atomically. Idempotency-Key honored.
GET/v1/submissionssubmissions:readList submissions, newest first.
POST/v1/contactscontacts:writeCreate or update by normalized email. Never duplicates. Idempotency-Key honored.
GET/v1/contactscontacts:readList contacts, newest first.
GET/v1/contacts/:idcontacts:readOne contact. Ids outside your workspace answer 404, never 403.
PATCH/v1/contacts/:idcontacts:writeUpdate attributes and custom fields. Email is the identity key and is not patchable in v1.
GET/v1/submissions/:submissionId/pdfsubmissions:readA 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/eventsevents:readThe append-only event stream, newest first.
POST/v1/emailsemail:sendSend 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/batchemail:sendSend 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/linksubmissions:readA 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 envelope
{
  "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.

verify-signature.ts
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.

index.html
<script src="https://popziq.com/e/<form_id>.js" async></script>
loader API
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.