# Webhooks

> Subscribe a target URL to new submissions, verify the signed delivery, and handle retries. The endpoints Zapier itself uses.

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](https://zapier.com/apps/maxforms/integrations) uses; see [the worked example](#worked-example-the-maxforms-zapier-app) below.

Everything here needs the `webhooks:write` scope, which only a workspace Admin or Owner can grant. See [scopes and who can grant them](/developers/api/overview#scopes-and-who-can-grant-them).

## Subscribe

```http
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_url` must be an HTTPS URL that resolves to a public address: non-HTTPS schemes, localhost, and private/link-local ranges are rejected with a standard `422` validation error pointing at `/data/attributes/target_url`. The full required-field list and validation rules are in the [API reference](/developers/api/reference).
- `event` defaults to `submission.created`, currently the only event. `platform` is optional; the only value the API currently accepts is `zapier`, and sending anything else returns a `422`. 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`:

```json
{
  "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:

```json
{
  "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

```http
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`:

```http
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":      "ada@example.com",
          "topics":     ["Mon", "Wed"]
        }
      }
    }
  ]
}
```

- `data.id` (also sent as `X-Maxforms-Delivery`) identifies this delivery. **Deduplicate on it**: a retried delivery repeats the same id.
- `included[0]` is the submission, in exactly the shape [`GET /v1/forms/{code}/submissions`](/developers/api/reference) returns: a sample you pull during setup and a live delivery can never disagree.
- `answers` is keyed by the field's bare **code**, not a `field_` 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 with [`GET /v1/forms/{code}/fields`](/developers/api/reference).
- `form_id` is 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:**

```js
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:**

```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 `2xx` immediately 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](https://zapier.com/apps/maxforms/integrations) 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](/notifications-integrations/zapier).

