Skip to main content

Hop API

Hop's partner API lets your own systems pull the data your flows collect, push submissions in, and manage flows. Version 1 lives under /v1.

Base URLs​

EnvironmentBase URL
Productionhttps://api.hopforms.com
Stagehttps://stage-api.hopforms.com

Stage is a separate database with its own keys. A key minted on stage does not work on production, and the other way round. GET /v1/me tells you which environment answered.

Version 1 is live on production and on stage; stage is where to try things.

Authentication​

Every request carries an API key in the Authorization header:

curl -sS -H "Authorization: Bearer hop_sk_..." https://api.hopforms.com/v1/me

You mint keys on the API page of your organisation's settings (/app/settings/api). The secret is shown once. A key carries the permissions (scopes) you chose when minting it, and a request outside them is a 403 that names the missing scope. Missing, malformed, revoked or expired keys all get the same 401. See Authentication for scopes, refusals and rate limits in full, including why flows:write deserves a second thought before you grant it.

Reading responses​

Success is JSON: a single record, or a list as {"data": [...], "next_cursor": ..., "has_more": ...}. Errors are application/problem+json with a stable code; the errors page lists every code and what to do about it.

Pagination​

Every list is paginated with a cursor, and there are no page numbers or offsets anywhere on the API. A page looks like this:

{ "data": [ ... ], "next_cursor": "8Kq2vX1nZ7rT0mB4sL9wC3jFyE6hPd", "has_more": true }

To read everything, follow next_cursor until has_more is false:

curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/groups?limit=100&after={cursor}"

limit defaults to 100 and its maximum is 500. Asking for more is an error rather than a quietly smaller page, so a page size you did not ask for can never be mistaken for the end of the data.

A cursor is a bookmark meaning "you have everything up to and including this row". Store and replay it verbatim: it is opaque, and constructing or editing one is not supported. Because it names a row rather than a position in a count, records arriving while you page through do not shift anything under you — which is exactly the failure page numbers have on data that is still being written.

For an incremental pull, start with updated_since and then follow cursors, and store the last cursor between runs rather than a wall-clock time. Timestamps here have one-second resolution, so several records routinely share one; a stored timestamp makes you re-read that whole second every run, while a cursor resumes exactly where you stopped.

Lists are ordered oldest-updated first. One consequence is worth designing for: a record that changes while you are paging moves to the end and you may see it twice, but nothing is ever skipped. updated_since is inclusive for the same reason, so a record updated exactly on your boundary can arrive on two consecutive runs.

Delivery is therefore at-least-once. Deduplicate by id and upsert rather than append, and a repeat is simply an update you would have wanted anyway.

Build your client for change. Inside /v1, Hop only ever adds: new endpoints, new optional request fields, new response fields, new enum values, new error codes. Nothing is removed, renamed, retyped or repurposed. So a client should ignore fields it does not recognise, treat an enum value it does not know as "other", and branch on code rather than on any human-readable text.

Every response the API answers carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A 429 adds Retry-After, in seconds. The allowances and the override are described under Authentication.

Submissions​

GET /v1/flows/{flow_id}/submissions lists a flow's submissions as the canonical record — the same shape the webhook sends. Four filters, and they combine freely: status, group_id, updated_since, contact_id.

curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows/{flow_id}/submissions?status=completed&group_id={group_id}&updated_since=2026-09-01T00:00:00Z"

You will not know a group's id ahead of time. That is what GET /v1/groups is for: list your organisation's groups once, then pass the id back as group_id.

For an incremental pull against this endpoint, use the recipe from Pagination above: pass updated_since once, then follow next_cursor until has_more is false, and store the cursor you stopped on rather than a wall-clock time between runs. Delivery is at-least-once; deduplicate by id.

contact and group on the record are both nullable: group is null when the contact has no group, the common case, and contact is null when the submission has no contact, for instance one your own integration created through this API.

Creating submissions​

POST /v1/flows/{flow_id}/submissions creates a submission the way a WhatsApp completion does, from the flow's point of view: it is born completed, marked initiated_by: "api", and it runs the same after-effects: notifications, your webhook, forwarding to a connected ODK or FHIR server, Sheets, the Data tab, and the flow's record action when it has one. Downstream it has the same shape and travels the same pipeline as a WhatsApp submission; only initiated_by says which channel it came through.

curl -sS -X POST -H "Authorization: Bearer hop_sk_..." \
-H "Idempotency-Key: bst-import-row-841" -H "Content-Type: application/json" \
"https://api.hopforms.com/v1/flows/{flow_id}/submissions" \
-d '{"answers": {"reading": "4.1", "context": "before_meal"}, "contact": {"phone": "+254712345678", "name": "mwashums"}, "completed_at": "2026-09-02T10:49:37Z"}'

Every key in answers must be a field of the flow's current published version; an unknown key is a 422 unknown_answer_field that names the closest field and lists the known ones, with at most ten unknown keys listed per response. Values are stored as you send them. contact is matched by phone in your organisation or created with consent unknown; the API can never mark someone as opted in, and name is kept only for a contact this request creates. An existing contact keeps the name Hop already has. Omit contact for a contactless submission. completed_at is for historical imports: not in the future, not before the flow was created, and it defaults to now. When the flow has a record action, the submission also creates or updates its register record, exactly as a WhatsApp completion would; the record write is a consequence of the submission completing, not a separately scoped action. submissions:write is the scope this whole request requires, and the response's record_id names the record.

Idempotency-Key is required. A network call has three outcomes, not two: it worked, it failed, or you do not know. Without a key, a retry after a timeout can record the same reading twice. So derive one string from the intent: the row you mean to create, say bst-import-row-841. Send that same string on every retry of that intent; never generate a fresh value per attempt. The first request that completes is the one that counts: a retry with the same key and body gets the same response back as a 200 with Idempotent-Replayed: true and creates nothing new. A retry after a timeout or a 5xx is safe with the same key, because a request that failed wrote nothing. The same key with a different body is a 422 idempotency_key_reuse. That is a bug in the client, and guessing which body you meant would be worse than refusing. The same key while the first attempt is still running is a 409 in_flight; wait a moment and retry. Keys are kept for at least 24 hours. A replayed 200 counts against the write bucket like any POST, and the response carries no Location header; the record's id names it.

The body is capped at 262144 bytes (256 KB), far above any real submission; over it, the 413 states the limit and, when you sent Content-Length, the bytes. Everything the request writes happens in one transaction, so a 4xx writes nothing. In particular, an update-mode flow whose record cannot be found from the answer at its record key is a 422 record_not_resolved: fix the record-key answer and retry with the same key.

CSV export​

The same path and the same filters return every matching submission as a single CSV file instead of a JSON page. Ask for it with Accept: text/csv, or ?format=csv where you cannot set a header, such as a plain download link.

curl -sS -H "Authorization: Bearer hop_sk_..." -H "Accept: text/csv" \
"https://api.hopforms.com/v1/flows/{flow_id}/submissions?status=completed" -o submissions.csv

Nine fixed columns come first — submission_id, status, contact_phone, contact_name, group_name, record_id, started_at, completed_at, updated_at — followed by the alphabetical union of every answer key in the matched set. Read columns by header name, never by position. The in-app CSV export once inserted a column in the middle of that fixed list and broke every consumer that read it by position; this API documents its columns by name for the same reason, and an answer column can appear or disappear as your flow changes. Timestamps end in Z.

The export is unpaginated: one request returns the whole matching set, so limit and after are rejected with CSV mode (both are a 400). If the connection drops partway through, re-run the same request. Reads are idempotent, so a retry is exactly as safe as the first attempt.

Attachments​

A submission's attachments[].url is a stable API path, not a download link. GET it and you get a 302 to a signed object-store URL that expires in 15 minutes. Follow the redirect immediately, and never store the URL you land on: it is a temporary permission in string form, and it may stop working before you come back to it. Keep the attachments[].url path instead, and resolve it again whenever you need the file.

The file is still being fetched from WhatsApp and stored when a submission first appears, so url can 404 for a short window even though the attachment is already listed. Retry after a few seconds instead of treating that 404 as permanent.

Registers​

A register is a maintained list, the drawer. A record is one entry in it, a folder for a household, a child, a facility. A flow's record action is the setting in the flow editor that files a record on every completed submission or updates an existing one. Records are never written directly over the API; a completed submission writes them, whether it arrived through WhatsApp or through POST /v1/flows/{flow_id}/submissions, and there is no direct record-write endpoint.

curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/registers?limit=100&after={cursor}"
curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/registers/{register_id}"
curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/registers/{register_id}/records?limit=100&after={cursor}&updated_since=2026-09-01T00:00:00Z&contact_id={contact_id}"
curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/registers/{register_id}/records/{record_id}"

For an incremental pull of records, use the recipe from Pagination: pass updated_since once, then follow next_cursor until has_more is false. A merge bumps a record's updated_at, so an updated record reappears on your next pull. Delivery is at-least-once. Deduplicate by id and keep the highest version.

The records list also answers as CSV: the same path and the same filters, under Accept: text/csv or ?format=csv. Six fixed columns come first, number, label, version, contact_phone, created_at, updated_at, followed by the record's property keys alphabetically. Read columns by header name, never by position. The export is unpaginated, so limit and after are rejected with CSV mode, both a 400. If the connection drops partway through, re-run the same request.

record_count on a register is counted live. A register's updated_at moves when the register itself changes: a new column, a rename, a flow save. It does not move on ordinary record activity, so watch records with updated_since, not registers.

A csv register, one uploaded in the app, answers the records list with an empty page: its rows are not records yet.

contact on a record is null when the record was created without a respondent. A record's label falls back to #<number>.

Flows​

A flow is the form. A version is one upload of it, and the published version is what WhatsApp serves to respondents. The Flow JSON comes back exactly as stored; the only bound on its size is the 5 MB the flow writes accept.

curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows?limit=100&after={cursor}"
curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows/{flow_id}"
curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows/{flow_id}/schema"
curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows/{flow_id}/source"

A flow's status comes from its current version when it has one, otherwise from its latest version. latest_version is the newest upload. current_version is the published one serving respondents. The two differ when a newer draft is in flight while an older version is still live.

groups is read-only here; attach or remove a group on the flow's Connect tab in the app. record_action is null when the flow writes no records.

The schema endpoint returns the Flow JSON document itself, with no envelope. Pass ?version= to read any version of yours in any state, including a draft still uploading or one that failed. Without version, a flow with nothing published answers 422 with no_published_version, the code a submission write also returns. A version that belongs to a different flow answers 404.

The source endpoint redirects the same way attachments do. It answers with a 302 to a signed link that expires in 15 minutes. A flow authored directly as Flow JSON, or one whose source form was not kept, has no source file, and answers 404 with a detail that says so.

A flow another organisation owns is not readable through these endpoints, even when your key can list submissions against it. The submissions endpoint keeps its own wider rule, because those submissions are your data. Each submission's flow stub still carries the id and name a pipeline needs.

Creating and publishing flows​

Getting a flow live is four requests. POST /v1/flows creates the flow and its first version and answers 202: the flow exists, the version is pending, and WhatsApp has not finished with the document. Poll GET /v1/flows/{flow_id}/versions/{version_id} with the latest_version.id the create returned. That version is the progress report, and every background step writes its outcome there. Poll every 2 to 3 seconds and back off after 30 seconds. Once state is draft and publishable is true, POST /v1/flows/{flow_id}/versions/{version_id}/publish takes it live, also a 202, so poll the same version again until state is published, or until failure_reason.stage is publish, which is how a refused publish shows up. A publish that fails leaves the version a draft, so state alone never tells you it is over.

curl -sS -X POST -H "Authorization: Bearer hop_sk_..." \
-H "Idempotency-Key: deploy-household-survey-v3" -H "Content-Type: application/json" \
"https://api.hopforms.com/v1/flows" \
-d "{\"name\": \"Household survey v3\", \"flow_json\": $(cat flow.json)}"
curl -sS -X POST -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows" \
-F name="Household survey v3" -F source_form=@form.xlsx
curl -sS -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows/{flow_id}/versions/{version_id}"
curl -sS -X POST -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows/{flow_id}/versions/{version_id}/publish"

A create carries either Flow JSON or a source form, never both. application/json takes the document in flow_json. multipart/form-data takes a source_form file part beside the same metadata fields, either an XLSForm workbook or a FHIR Questionnaire, and Hop converts it before this request answers. So a conversion problem is immediate rather than something you poll for: 422 conversion_failed quotes the converter's own sentence, 504 converter_timeout and 502 converter_unavailable mean nothing was created and you can retry. The create response's warnings lists what the converter dropped, degraded or approximated on the way to Flow JSON. Read it. A warning is the converter telling you the flow it built is not quite the form you uploaded. warnings is empty for Flow JSON you send yourself, because nothing converted it.

Nothing in Hop validates the document. WhatsApp does, during the upload, which is why the verdict arrives on the version and never on the create response. A document WhatsApp rejects ends failed with failure_reason, which names the stage that failed, the HTTP status WhatsApp answered and WhatsApp's message. status is null when the step that failed was Hop's own configuration. An accepted document reaches draft with a preview_url, a page WhatsApp hosts that renders the draft. That link expires, so poll for a fresh one rather than storing it. publishable is true exactly when a publish would be accepted, meaning the version is a draft whose upload has finished. Branch on codes rather than on states where you can: a publish refused up front is a 409 not_in_draft_state or draft_not_yet_uploaded, and an unpublish on a version that is not the published one is a 409 not_published. validation_errors holds what WhatsApp said about a document it accepted anyway, and is empty when it said nothing. Each entry carries WhatsApp's code and message, its pointer path into the document, and the screen id and component name Hop resolves from that path. Read code and message first: today WhatsApp reports where an error sits as a line and column into the uploaded document rather than as a path, so path, screen and component are null until it sends one. When it does, reading one version on its own always resolves the two; in a page of versions Hop resolves them for the first 100 annotated versions and leaves them null beyond that.

number_id is the WhatsApp number the flow answers on. Leave it out and Hop uses your organisation's number, or answers 422 no_number_configured when your organisation has none. Send one and it has to be a number linked to your organisation. Another organisation's number is a 422 validation_failed on number_id, the same answer as a number that does not exist.

POST /v1/flows/{flow_id}/versions adds a later version from Flow JSON, 202 and pending, polled exactly like the first one. Send a document the flow already has as a pending, draft or failed version and you get a 200 with that version as it stands, so a deploy script that runs twice creates nothing. A document WhatsApp rejected comes back that way too, as a 200 on the failed version, so change the document before you post again. One version is picked up again rather than handed back: one that failed at Hop's own configuration step, before WhatsApp ever saw the document. That answers 202 with the same version, pending again. A version that was published, or has since been deprecated, is neither reused nor republished, so post the Flow JSON as a new version instead. A flow with no number of its own, in an organisation with none linked, answers 422 no_number_configured; link a number in the app, then post again.

PATCH /v1/flows/{flow_id} changes settings and nothing else: name, description, confirmation message, visibility, notify contacts. Omitted keys keep their value. Ownership, status and versions are never writable there, and the document is never edited in place, so a change to the form itself is always a new version. visibility is the one setting that reaches past your organisation. public claims the keywords a respondent texts to open this flow across all of Hop. It also opens the flow to anyone. Any phone number that texts a keyword can submit, and Hop creates the contact on first use. A private flow accepts only contacts your organisation already has. A 409 code_conflict means another organisation already holds one of those keywords publicly, and nothing in the request was applied.

POST /v1/flows/{flow_id}/versions/{version_id}/unpublish takes the published version off WhatsApp and answers 202. The 202 body still shows the version published, because the deprecation runs in the background. Once the job has run the version is deprecated, the flow's status is inactive, and its current_version is null. It goes one way. A deprecated version is never republished, so to go live again you post the Flow JSON as a new version and publish that.

DELETE /v1/flows/{flow_id} deletes the flow, every version, every submission collected through it, and the files those submissions stored. There is no undo, which is the other reason to guard a key holding flows:write. It answers 202 and the work runs in the background, so the flow reads unchanged until the job runs, usually within seconds, and answers 404 after that. A published flow has to be unpublished first: 409 must_unpublish_first. One thing outlives the delete. WhatsApp only deletes drafts, so once any version of a flow has been published, Hop skips WhatsApp entirely for that flow and its objects stay there as deprecated flows even after the flow is gone from Hop. A flow that never had a published version is the exception, and Hop deletes its draft objects on WhatsApp as well.

Idempotency-Key is accepted on each of the four POSTs in this section and required on none of them, unlike POST /v1/flows/{flow_id}/submissions, which demands one. Send it anyway on a create. A retry after a timeout then replays the first response as a 200 with Idempotent-Replayed: true instead of creating a second flow. That header is also what tells a replay apart from the other 200 on POST /v1/flows/{flow_id}/versions, which means "this flow already had that document" and carries no header. The same key with a different body is a 422 idempotency_key_reuse, a client bug rather than something Hop should guess at. The same key while the first attempt is still running is a 409 in_flight; wait a moment and read the result instead of sending again.

Hop caps the two create bodies at 5242880 bytes (5 MB) each, twenty times the cap on every other route, because a Flow JSON document can reach 1 MB and a spreadsheet 2 MB. Over the cap, the 413 request_too_large states the limit and, when you sent Content-Length, the bytes. The PATCH and the DELETE keep the ordinary 262144-byte cap, which no settings change comes near.

Record actions​

The editor's Records card decides what completing a flow does to a register: file a new record, update one, or do whichever applies. The flows list already reports the result as record_action; PUT and DELETE /v1/flows/{flow_id}/record-action are how you set it.

curl -sS -X PUT -H "Authorization: Bearer hop_sk_..." -H "Content-Type: application/json" \
"https://api.hopforms.com/v1/flows/{flow_id}/record-action" \
-d '{"mode": "create", "label_key": "head_name", "excluded_keys": ["notes"]}'
curl -sS -X DELETE -H "Authorization: Bearer hop_sk_..." \
"https://api.hopforms.com/v1/flows/{flow_id}/record-action"

PUT is a full replacement: every setting you don't send is cleared. Omit register_id, or send it as null, and Hop provisions the flow's own register, named after the flow; the same omission on a later call points back at that register and keeps whatever name it has picked up since. The first PUT on a flow answers 201; replacing an existing action answers 200. A register_id you supply must be one of your own registers fed by a flow (kind: flow); one that does not exist, or belongs to another organisation, is 404, and one of your own CSV registers is 422 not_a_flow_register.

label_key and every excluded_keys entry must name a field of the flow's current version: the published version when there is one, else the newest. Any named component counts, pickers included. record_id_key must be an option list of that version bound to the target register; it is refused with not_applicable on a create action, and a null value on update or create_or_update is allowed: only a send started from a record can then aim the flow at one. On the record-action write, errors[].code is one of unknown, not_register_bound, not_applicable, no_processed_version or not_a_flow_register, with a did-you-mean when a near match exists among the field or binding names.

Settings that name fields need a version WhatsApp has processed: while the flow's version is still pending (or the flow has none yet), sending label_key, record_id_key or a non-empty excluded_keys is 422 no_processed_version. mode on its own, with or without register_id, is accepted at any time.

This endpoint does not use an Idempotency-Key; one you send is ignored. Because the PUT replaces the whole action, retrying after a lost response lands on the same state either way; the only thing a retry can see differently is a 200 where the first attempt would have answered 201.

DELETE stops future completions from writing records. It never touches existing records or submissions. The flow's own backing register goes with the action only when the action provisioned it, it holds no record, and nothing else uses it. It stays when it holds records, when it belongs to another flow, or when another flow's action writes into it. A 409 register_in_use means an option list on some flow still reads the register that would go; the detail says which flow, or that it is this flow's own version, and the flow editor is where you remove the binding. A repeat DELETE, or one on a flow with no action, answers 204.

A two-flow setup follows the same order: create the registration flow and set its action with mode: "create"; build the follow-up flow with an option list bound to the registration flow's register, wired in the editor; then set the follow-up's action with mode: "update", register_id the registration flow's register, and record_id_key that option list's field name.

Reference​

The reference is generated from the same description the server validates requests against, so what it says an endpoint accepts is what the endpoint accepts. Each page has a runnable example in curl, Python and Node.

Exploring a running server​

On developer laptops and on stage, the app serves Hop staff an interactive explorer of exactly the API build that is running. It shows the live description; it does not send requests. Production has no explorer; this site is its public face.