PopzIQ

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

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.

Live event types and when each is emitted
TypeEmitted when
form.viewedA published form renders through the embed or its link page.
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.
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.

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.

Live /v1 routes with required scope
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/eventsevents:readThe append-only event stream, newest first.

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 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.

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