A webhook subscription tells Maxforms to POST a signed JSON:API document to your URL every time a subscribed form receives a submission. This is the same mechanism the Maxforms Zapier app uses; see the worked example below.
Everything here needs the webhooks:write scope, which only a workspace Admin or Owner can grant. See scopes and who can grant them.
Subscribe#
POST /v1/webhooks
Authorization: Bearer <token>
Content-Type: application/vnd.api+json
{
"data": {
"type": "webhooks",
"attributes": {
"target_url": "https://hooks.example.com/maxforms",
"event": "submission.created",
"platform": "zapier"
},
"relationships": {
"form": { "data": { "type": "forms", "id": "aZ3kP9mQ7nB4" } }
}
}
}
target_urlmust be an HTTPS URL that resolves to a public address: non-HTTPS schemes, localhost, and private/link-local ranges are rejected with a standard422validation error pointing at/data/attributes/target_url. The full required-field list and validation rules are in the API reference.eventdefaults tosubmission.created, currently the only event.platformis optional; the only value the API currently accepts iszapier, and sending anything else returns a422. Omit the field if you are not Zapier. Setting it is what makes a form's Integrate tab show a subscription count.- A form outside the token's workspace returns
404.
Idempotent on the (form, target URL, event) triple. Retrying after a timeout is safe: the same subscription and id come back rather than a second row, so a retry can never double your deliveries.
A new subscription returns 201 with a one-time secret:
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "webhooks",
"id": "01HW3P8RQVK7F1B2N9X6C4D5EA",
"attributes": {
"form_id": "aZ3kP9mQ7nB4",
"target_url": "https://hooks.example.com/maxforms",
"event": "submission.created",
"platform": "zapier",
"secret": "9f1c3b7a2e5d8046c1a9f0b3d7e2c4a8f6019b5d3e7c1a4f8b2d6e0c9a3f7b15",
"created_at": "2026-08-22T10:14:32+00:00"
}
}
}
The secret is returned exactly once, here. Store it: it is not retrievable later, and it is what you use to verify every delivery below. The same call again returns 200 with the same id and no secret key at all:
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "webhooks",
"id": "01HW3P8RQVK7F1B2N9X6C4D5EA",
"attributes": {
"form_id": "aZ3kP9mQ7nB4",
"target_url": "https://hooks.example.com/maxforms",
"event": "submission.created",
"platform": "zapier",
"created_at": "2026-08-22T10:14:32+00:00"
}
}
}
A subscription created before Maxforms started signing deliveries has no secret at all: the same idempotent POST above still returns 200 with no secret key, not because it was already shown to you, but because there has never been one to show. That subscription keeps delivering unsigned. There is no rotate endpoint and no backfill. If a delivery arrives with no X-Maxforms-Signature header, DELETE the subscription and POST the same body again to get a fresh one with a secret.
Unsubscribe#
DELETE /v1/webhooks/{id}
Authorization: Bearer <token>
HTTP/1.1 204 No Content
{id} is the id returned by subscribe. A subscription belonging to another workspace, or one that no longer exists, returns 404.
The delivery#
When a subscribed form is submitted, Maxforms sends an HTTP POST to target_url:
POST /your-target-url HTTP/1.1
Content-Type: application/vnd.api+json
X-Maxforms-Event: submission.created
X-Maxforms-Delivery: 01HV7F3XQK9M2N4P6R8T0W1Y3Z
X-Maxforms-Signature: t=1755861272,v1=5a7e2b1c...
{
"data": {
"type": "events",
"id": "01HV7F3XQK9M2N4P6R8T0W1Y3Z",
"attributes": {
"event": "submission.created",
"created_at": "2026-08-22T10:14:32+00:00"
},
"relationships": {
"submission": { "data": { "type": "submissions", "id": "01HV7F5YQK9M2N4P6R8T0W1Y42" } }
}
},
"included": [
{
"type": "submissions",
"id": "01HV7F5YQK9M2N4P6R8T0W1Y42",
"attributes": {
"form_id": "aZ3kP9mQ7nB4",
"submitted_at": "2026-08-22T10:14:32+00:00",
"answers": {
"name": "Ada Lovelace",
"email": "[email protected]",
"topics": ["Mon", "Wed"]
}
}
}
]
}
data.id(also sent asX-Maxforms-Delivery) identifies this delivery. Deduplicate on it: a retried delivery repeats the same id.included[0]is the submission, in exactly the shapeGET /v1/forms/{code}/submissionsreturns: a sample you pull during setup and a live delivery can never disagree.answersis keyed by the field's bare code, not afield_prefix, and keeps its native JSON type: a multi-value answer (checkbox group, multi-select) is a JSON array, a composite field (address) is an object, and only single-value fields are strings. Resolve a code to a human label withGET /v1/forms/{code}/fields.form_idis the form's public code, never an internal id.
Verify the signature#
X-Maxforms-Signature follows Stripe's scheme: t=<unix timestamp>,v1=<hex HMAC-SHA256>, computed over "{timestamp}.{raw body}" with your subscription's secret as the key. Compare against the raw request body bytes, before any JSON parsing. Re-serializing the parsed JSON can reorder keys or change whitespace, and the signature will no longer match.
Node:
const crypto = require('crypto');
function verifyMaxformsSignature(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const timestamp = parseInt(parts.t, 10);
const signature = parts.v1 ?? '';
if (!Number.isFinite(timestamp) || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
return false;
}
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const expectedBuffer = Buffer.from(expected, 'hex');
const signatureBuffer = Buffer.from(signature, 'hex');
return expectedBuffer.length === signatureBuffer.length && crypto.timingSafeEqual(expectedBuffer, signatureBuffer);
}
PHP:
function verifyMaxformsSignature(string $secret, string $rawBody, string $header, int $toleranceSeconds = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$key] = $value;
}
$timestamp = (int) ($parts['t'] ?? 0);
$signature = $parts['v1'] ?? '';
if (abs(time() - $timestamp) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);
return hash_equals($expected, $signature);
}
The tolerance above (300 seconds, 5 minutes) matches what Maxforms itself accepts when it is asked to verify a signature; a header older or newer than that fails regardless of the secret. If your framework buffers or re-encodes the request body before your handler sees it, capture the byte stream as early as possible in the request lifecycle.
Retries, timeouts and idempotency#
Your endpoint must answer 2xx within 30 seconds. Anything else (a non-2xx status, a connection error, a timeout) counts as a failed attempt. Redirects are never followed: a 3xx answer is a failure, so make target_url the final URL.
Maxforms makes up to 5 attempts total, backing off 30 seconds, 2 minutes, 10 minutes, then 30 minutes between them. After the fifth failed attempt the delivery is marked permanently failed and Maxforms stops.
- Make it idempotent. A slow-but-successful response that exceeds the 30 second timeout is retried anyway, so the same delivery (
data.id/X-Maxforms-Delivery) can reach you more than once. - Answer fast, work later. Acknowledge with a
2xximmediately and queue your own processing. Doing the work inline is the usual cause of a timeout, and a timeout costs you a duplicate, not a saved retry.
Worked example: the Maxforms Zapier app#
The Maxforms Zapier app is one consumer of this API, not a separate surface. Nothing it does is unavailable to your own client:
| Zapier step | Endpoint | Scope |
|---|---|---|
| Connect a Maxforms account | OAuth 2.0 Authorization Code grant | forms:read, submissions:read, webhooks:write |
| Label and re-test the connection | GET /v1/me |
None |
| Populate the form picker | GET /v1/forms |
forms:read |
| Label the output fields | GET /v1/forms/{code}/fields |
forms:read |
| Pull sample data for the test step | GET /v1/forms/{code}/submissions |
submissions:read |
| Turn a Zap on | POST /v1/webhooks |
webhooks:write |
| Turn a Zap off | DELETE /v1/webhooks/{id} |
webhooks:write |
It requests all three scopes at once because a working Zap needs the form picker, the sample-data step, and subscription management. Request only the scopes you use: an embed integration needs forms:read alone.
Setting up a Zap rather than building a client? See Connect your form to Zapier.