API Reference

Instaform API

Read your Instaform forms and submissions, and subscribe to new ones, over plain HTTP JSON, plus signed outbound webhooks the moment a response lands. Nine endpoints, one bearer token, no SDK required.

Overview

Base URL

https://api.instaform.co

Every path below is relative to this origin, so listing forms is a GET to https://api.instaform.co/public/v1/forms.

Availability

API access is included on the Pro and Enterprise plans only. On any other plan every endpoint returns 403 with code feature_unavailable.

v1 is read-only, with one exception

Every endpoint is a GET except the hook subscriptions: POST /public/v1/hooks and DELETE /public/v1/hooks/{id}. Those two exist because Zapier subscribes and unsubscribes rather than polling, and there is no read-shaped version of either. There is still no way to create, update or delete a form, a submission or anything else through this API.

Any other verb on any other path returns 405 with code read_only_api, whether or not you sent a valid key.

Writable keys

Keys are created read-only. The two hook write calls need a key created with the Writable box ticked in Settings; any other key gets 403 with code read_only_key. Reading the hook list and the sample needs nothing special. This is not a plan refusal and upgrading does not lift it, so keep one writable key for Zapier and leave every other key read-only.

The hook endpoints also require the zapier plan feature, on Pro and above, on top of the API access every endpoint needs.

OpenAPI specification

This page is written from a machine-readable OpenAPI 3.0 document, published at:

It needs no API key and no paid plan, and it is the one endpoint that answers cross-origin requests, so you can load it straight into Swagger UI, Postman or your generator of choice. A CI test checks it against the live routes on every build, so it cannot quietly drift from what the API actually serves.

Authentication

Create a key under Settings → API keys, then send it as a bearer token on every request.

The full key is shown exactly once, at creation. Instaform stores only a hash of it, so a lost key cannot be recovered — create a new one and revoke the old.

curl -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/forms"

One generic 401, on purpose

A missing key, a malformed header, an unknown key, a revoked key and an expired key all return the same 401 body. This is deliberate: telling the two apart would confirm to whoever holds a leaked key that it was genuine and merely stale.

{
  "error": "Invalid API key.",
  "code": "invalid_api_key"
}

Rate limits

Up to 1000 requests per clock-hour per key. The window is fixed (aligned to the top of the clock hour), not a rolling/sliding window, so a burst that spans the hour boundary may briefly exceed 1000 requests within any given 60-minute span (up to ~2000 in the worst case — ~1000 late in one hour plus ~1000 early in the next).

Exceeding the per-window limit returns 429 with a Retry-After header (seconds until the current window resets).

The limit is counted per key, not per account and not per IP — issuing a second key for a second integration gives that integration its own budget.

{
  "error": "Rate limit exceeded. Please try again later.",
  "retry_after": 1284
}

Pagination

List endpoints take page and per_page query parameters. per_page defaults to 20 and is capped at 100 — a larger value is clamped rather than rejected. Every list response carries a meta object alongside data.

"meta": {
  "current_page": 1,
  "total_pages": 7,
  "total_count": 128,
  "per_page": 20
}

total_count counts the readable set only — held submissions are excluded from it. See held submissions below.

Endpoints

GET/public/v1/forms

List forms

Returns forms belonging to the authenticated account, most recently created first. submissions_count excludes held submissions.

Parameters

  • page query — Page number. Defaults to 1.
  • per_page query — Items per page. Defaults to 20, capped at 100.

Request

curl -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/forms?page=1&per_page=20"

Response 200

{
  "data": [
    {
      "id": 12,
      "hash_id": "a1b2c3d4e5",
      "title": "Contact us",
      "description": "Website contact form",
      "status": "published",
      "submissions_count": 128,
      "created_at": "2026-01-14T09:12:03Z",
      "updated_at": "2026-02-02T17:40:55Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "total_pages": 3,
    "total_count": 47,
    "per_page": 20
  }
}
GET/public/v1/forms/{id}

Get one form, including its field schema

A form id that does not belong to the authenticated account returns 404, never 403 — the distinction would confirm the record exists on someone else’s account.

Parameters

  • id path — The form id. Required.

Request

curl -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/forms/12"

Response 200

{
  "data": {
    "id": 12,
    "hash_id": "a1b2c3d4e5",
    "title": "Contact us",
    "description": "Website contact form",
    "status": "published",
    "submissions_count": 128,
    "created_at": "2026-01-14T09:12:03Z",
    "updated_at": "2026-02-02T17:40:55Z",
    "fields": [
      {
        "id": "field_0",
        "type": "email",
        "label": "Email",
        "required": true
      }
    ]
  }
}
GET/public/v1/forms/{form_id}/submissions

List submissions for one form

Held submissions (over your plan’s monthly limit) are omitted from both the results and meta.total_count. A form_id that does not belong to the authenticated account returns 404, never 403.

Parameters

  • form_id path — The form id. Required.
  • page query — Page number. Defaults to 1.
  • per_page query — Items per page. Defaults to 20, capped at 100.

Request

curl -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/forms/12/submissions?page=1&per_page=50"

Response 200

{
  "data": [
    {
      "id": 981,
      "hash_id": "f7e6d5c4b3",
      "form_id": 12,
      "form_title": "Contact us",
      "data": {
        "field_0": "[email protected]",
        "field_1": "Do you take weekend bookings?"
      },
      "created_at": "2026-02-02T17:40:55Z",
      "updated_at": "2026-02-02T17:40:55Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "total_pages": 7,
    "total_count": 128,
    "per_page": 50
  }
}
GET/public/v1/submissions

List submissions across all your forms

Held submissions are omitted from both the results and meta.total_count. Optionally scope to one form with form_id.

Parameters

  • form_id query — Restrict to submissions for this form id.
  • page query — Page number. Defaults to 1.
  • per_page query — Items per page. Defaults to 20, capped at 100.

Request

curl -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/submissions?form_id=12"

Response 200

{
  "data": [
    {
      "id": 981,
      "hash_id": "f7e6d5c4b3",
      "form_id": 12,
      "form_title": "Contact us",
      "data": {
        "field_0": "[email protected]",
        "field_1": "Do you take weekend bookings?"
      },
      "created_at": "2026-02-02T17:40:55Z",
      "updated_at": "2026-02-02T17:40:55Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "total_pages": 7,
    "total_count": 128,
    "per_page": 20
  }
}
GET/public/v1/submissions/{id}

Get one submission

A held submission, or a submission id that does not belong to the authenticated account, both return a plain 404 — the API does not distinguish "does not exist" from "held over your plan limit".

Parameters

  • id path — The submission id. Required.

Request

curl -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/submissions/981"

Response 200

{
  "data": {
    "id": 981,
    "hash_id": "f7e6d5c4b3",
    "form_id": 12,
    "form_title": "Contact us",
    "data": {
      "field_0": "[email protected]",
      "field_1": "Do you take weekend bookings?"
    },
    "created_at": "2026-02-02T17:40:55Z",
    "updated_at": "2026-02-02T17:40:55Z"
  }
}
GET/public/v1/hooks

List hook subscriptions

Every Zapier REST Hook subscription on the account, most recently created first. The signing secret is not included: it is returned once, by the POST that created the subscription, and cannot be read back.

Parameters

  • page query — Page number. Defaults to 1.
  • per_page query — Items per page. Defaults to 20, capped at 100.

Request

curl -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/hooks"

Response 200

{
  "data": [
    {
      "id": 7,
      "target_url": "https://hooks.zapier.com/hooks/standard/123/",
      "event": "submission.created",
      "active": true,
      "last_status": 200,
      "last_delivered_at": "2026-02-02T17:41:02Z",
      "created_at": "2026-02-01T09:12:00Z"
    }
  ],
  "meta": { "current_page": 1, "total_pages": 1, "total_count": 1, "per_page": 20 }
}
POST/public/v1/hooksWritable key

Subscribe to submission events

Registers a target URL to receive submission.created events for every form on the account, including forms created afterwards. Idempotent: posting a target_url that is already subscribed returns the existing subscription rather than creating a second one, and re-activates one that was auto-disabled after repeated delivery failures. The response is the only place the signing secret ever appears. At most 25 subscriptions per account.

Parameters

  • target_url body — Public https URL to POST events to. Required. Private, loopback and link-local addresses are refused, both here and again at delivery time.
  • event body — Only submission.created is supported. Defaults to it.

Request

curl -X POST -H "Authorization: Bearer inst_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"target_url":"https://example.com/hooks/instaform"}' \
  "https://api.instaform.co/public/v1/hooks"

Response 201

{
  "data": {
    "id": 7,
    "target_url": "https://example.com/hooks/instaform",
    "event": "submission.created",
    "active": true,
    "last_status": null,
    "last_delivered_at": null,
    "created_at": "2026-02-01T09:12:00Z",
    "secret": "3f9c…shown once, never again"
  }
}
GET/public/v1/hooks/sample

Sample events, for field mapping

Up to three recent submissions rendered as submission.created events, so an integration can be built before any real event has been delivered. The keys are identical to a live delivery. Held submissions are excluded, exactly as they are from every other read endpoint, and an empty array is a valid answer.

Parameters

  • None.

Request

curl -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/hooks/sample"

Response 200

{
  "data": [
    {
      "event": "submission.created",
      "form": { "id": 12, "hash_id": "a1b2c3d4", "name": "Contact us" },
      "submission": {
        "id": 981,
        "field_data": [
          { "name": "email", "content": "[email protected]", "group": "text" }
        ],
        "created_at": "2026-02-02T17:40:55Z"
      },
      "delivered_at": "2026-02-02T17:41:02Z"
    }
  ]
}
DELETE/public/v1/hooks/{id}Writable key

Unsubscribe

Removes the subscription and returns 204 with no body. A subscription id that does not belong to the authenticated account returns 404, never 403. This is not the only way a subscription ends: answering a delivery with HTTP 410 Gone also removes it, which is how Zapier signals a Zap has been switched off.

Parameters

  • id path — The subscription id. Required.

Request

curl -X DELETE -H "Authorization: Bearer inst_live_xxxxxxxx" \
  "https://api.instaform.co/public/v1/hooks/7"

Response 204

204 No Content

Webhooks

Webhooks are the push half of the developer bundle: instead of polling the endpoints above, register a URL and Instaform POSTs to it as soon as a response is stored. They are configured per form, not per account — open a form under your forms and add the URL in its Webhooks panel.

Included on the Pro and Enterprise plans, the same tiers that carry API access. The URL must be a public http(s) address; private, loopback and link-local addresses are rejected when you save it and re-checked on every delivery.

One event

Every delivery carries submission.created, in the body as event and in the X-Instaform-Event header. It fires once the submission row is committed, so anything the payload names can be fetched back over the REST API immediately.

POST /hooks/instaform HTTP/1.1
Host: your-endpoint.example.com
Content-Type: application/json
X-Instaform-Signature: sha256=4f2a1c9e0b7d63a8...
X-Instaform-Event: submission.created
User-Agent: Instaform-Webhooks/1.0

Payload

The body is JSON with four top-level keys: event, form, submission and delivered_at.

{
  "event": "submission.created",
  "form": {
    "id": 12,
    "hash_id": "a1b2c3d4e5",
    "name": "Contact us"
  },
  "submission": {
    "id": 981,
    "field_data": [
      { "name": "field_0", "content": "[email protected]", "group": "default", "is_file": false },
      { "name": "field_1", "content": "Do you take weekend bookings?", "group": "default", "is_file": false }
    ],
    "created_at": "2026-02-02T17:40:55Z"
  },
  "delivered_at": "2026-02-02T17:40:56Z"
}
  • form.name is the form title as shown in your dashboard, and form.hash_id is the public id from its embed URL.
  • submission.field_data is an array of item objects — each carries the field's name (the field id), content, and is_file. Find an answer with field_data.find(i => i.name === fieldId) and fetch the form to map a field id to its label.
  • delivered_at is stamped when this attempt is built, not when the response arrived, so a retry of the same submission carries a later value and therefore a different body and a different signature. It is not an identifier.

Verifying the signature

Every request carries X-Instaform-Signature: the string sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. Compute the same value and compare the two in constant time.

The secret is a 64-character hex string shown exactly once, in the response that creates the webhook. Instaform cannot show it again — if you lose it, delete the webhook and add it back to get a new one.

const crypto = require('crypto')
const express = require('express')

const app = express()

// express.raw, not express.json: the signature covers the bytes we sent, and
// parsing then re-serialising can reorder a key and invalidate every signature.
app.post('/hooks/instaform', express.raw({ type: 'application/json' }), (req, res) => {
  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', process.env.INSTAFORM_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex')

  const a = Buffer.from(expected)
  const b = Buffer.from(req.get('X-Instaform-Signature') || '')

  // Length first: timingSafeEqual throws on a length mismatch.
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).end()
  }

  const payload = JSON.parse(req.body.toString('utf8'))

  // At-least-once. Dedupe on the submission id before doing any real work.
  if (alreadyHandled(payload.submission.id)) return res.status(200).end()

  handleSubmission(payload)
  res.status(200).end()
})
class InstaformWebhooksController < ActionController::API
  def create
    # raw_post, not params: the signature covers the bytes as sent.
    body = request.raw_post
    expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
    received = request.headers['X-Instaform-Signature'].to_s

    return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, received)

    payload = JSON.parse(body)

    # At-least-once. Dedupe on the submission id before doing any real work.
    return head :ok if Delivery.exists?(submission_id: payload.dig('submission', 'id'))

    HandleSubmission.call(payload)
    head :ok
  end

  private

  def secret
    ENV.fetch('INSTAFORM_WEBHOOK_SECRET')
  end
end

No timestamp in the scheme, so dedupe on the submission id

The signature covers the body and nothing else. There is no timestamp header and no nonce, which means a valid signature proves the body came from Instaform but says nothing about when, and gives you no replay window to reject. Treat delivery as at-least-once: the same submission can legitimately reach you more than once, and a duplicate is indistinguishable from the first attempt except by its contents.

Key your idempotency off submission.id, which is stable across every attempt. Do not hash the body and do not use delivered_at: both change between retries of the same response.

Retries and auto-disable

A delivery is attempted up to five times, with polynomially increasing waits between attempts. What counts as worth retrying:

ResponseWhat happens
Any 2xxDelivered. The consecutive-failure streak resets to zero and the webhook records the status and the time.
429, or any 5xxRetried. These say the endpoint is overloaded or broken rather than refusing us, so repeating the call can succeed.
Timeout or connection errorRetried. Connect and read each time out after 5 seconds; refused connections, resets, unreachable hosts, DNS failures and TLS errors all land here.
Any other 4xxTerminal. A 400, 401, 404 or 410 is the endpoint’s answer, and repeating the request would only repeat the answer. The attempt still counts against the failure streak.
Any 3xxTerminal. Redirects are not followed, so a moved endpoint fails like any other non-2xx. Register the final URL rather than one that redirects to it.

Every failed attempt increments a consecutive-failure counter, and five consecutive failures switch the webhook off. Because the five attempts for a single submission each count, one genuinely dead endpoint can exhaust the streak on one response. A single successful delivery resets the counter to zero.

A disabled webhook stops delivering and does not re-enable itself. Fix the endpoint and switch it back on from the form’s Webhooks panel. Deliveries also stop if the account drops below Pro: the plan is re-checked at delivery time, not only when the webhook was created.

One failure skips the streak entirely. If the URL stops resolving to a public address between deliveries, that delivery is abandoned and the webhook is switched off immediately, without four more attempts. A host that starts pointing inside a private network is not a flaky endpoint, and retrying it is the thing the check exists to prevent.

Both timeouts are 5 seconds, so acknowledge fast and do the work afterwards. An endpoint that verifies the signature, enqueues a job and returns 200 will never be retried for being slow; one that processes inline and answers in six seconds will be retried while the first call is still running.

Held submissions

Submissions held over your plan’s monthly limit are omitted entirely from this API. They are absent from list results, excluded from total_count and submissions_count, and fetching one directly by id returns 404. Upgrade to unlock them.

The 404 is a plain one: the API does not distinguish “does not exist” from “held over your plan limit”. That is what stops the counts from leaking how many rows are being withheld, so do not treat a 404 as proof that a submission was never received.

Because the exclusion happens in the query rather than at serialization, pagination and totals are computed over the readable set — page counts stay consistent with what you can actually fetch.

Error responses

Errors are JSON with an error message and, except on 429, a stable code to branch on.

StatuscodeWhen
401invalid_api_keyMissing, malformed, unknown, revoked, or expired API key. All five return this identical body.
403feature_unavailableThe account’s current plan does not include the capability. The body also carries feature — "api_access" on any endpoint, or "zapier" on the hook endpoints — plus current_plan and required_plan.
403read_only_keyA POST or DELETE to /public/v1/hooks with a key that was not created writable. Not a plan refusal: create a writable key instead of upgrading.
404not_foundThe record does not exist, belongs to another account, or — for a submission — is held over your plan’s monthly limit.
405read_only_apiAny non-GET method outside the hook endpoints, which are the only write surface.
422invalid_hookA subscription was refused: a missing or non-public target_url, an unknown event, or the 25-per-account limit. error carries a sentence safe to show a user.
429—Rate limit exceeded for the current clock-hour window. The body carries retry_after and the response carries a Retry-After header.