REST API
The JomForm REST API lets your own server create forms, read sales and responses, manage products, subscriptions, webhooks and domains. It is served from https://api.jomform.com and currently exposes 119 operations, every one of them listed on this page.
A machine-readable OpenAPI 3.1 contract for the same surface is published at https://jomform.com/openapi.json. It is generated from the API's own route table and checked against it on every test run, so it cannot drift from the endpoints you actually have.
Start in 5 minutes
- Sign in and open Settings → API keys, then create a key. Choose
readwriteif it needs to create or change anything. The raw key is shown once; copy it then. You can also create one over the API itself withPOST /v1/rest/keys. - Export it in your shell — the API never takes a key in the query string, and this page never prints a real one:
export JOMFORM_API_KEY="jfk_your_key_here" - Make your first call. Every example on this page is executed against a live server by the test suite, so these commands are known to work.
1. Authenticate
Every /v1/rest route takes the key in an Authorization header. There is no query-string key and no cookie path for a script.
curl -sS 'https://api.jomform.com/v1/rest/keys' \
-H "Authorization: Bearer $JOMFORM_API_KEY"Expect HTTP 200, and a body containing "scopes". This lists your own keys — it never returns a secret. The raw key is shown once, when you create it.
2. What a missing credential gets
An unauthenticated call is refused with 401 before any account is resolved, so a misconfigured client fails loudly instead of reading someone else's data.
curl -sS 'https://api.jomform.com/v1/rest/keys'Expect HTTP 401, and a body containing "error". The body is {"error":"auth diperlukan"} — the API's own wording, not a fixed error catalogue.
3. Create a form
A new form is created as a draft. Nothing is live until you publish it.
curl -sS -X POST 'https://api.jomform.com/v1/rest/forms' \
-H "Authorization: Bearer $JOMFORM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Docs example form"}'Expect HTTP 201, and a body containing "slug". Leave slug out and it is generated from the title and made unique in the workspace. The response carries mode (payment by default) and the draft's url.
4. Publish it
Publishing is what makes the form live at its URL.
curl -sS -X POST 'https://api.jomform.com/v1/rest/forms/{{form_id}}/publish' \
-H "Authorization: Bearer $JOMFORM_API_KEY"Expect HTTP 200, and a body containing "status". A payment form with no products is refused here with 409 and requires_ack: true, because it could never take money; send {"acknowledge":true} to publish it anyway.
5. Read your forms back
The list carries everything needed to pick a form id: slug, title, status, mode and public URL.
curl -sS 'https://api.jomform.com/v1/rest/forms' \
-H "Authorization: Bearer $JOMFORM_API_KEY"Expect HTTP 200, and a body containing "mode". mode is payment or response, so a response form is never mistaken for an order form.
6. Read the sales this form produced
The last step of the end-to-end flow: what the form actually recorded.
curl -sS 'https://api.jomform.com/v1/rest/sales?page=1&limit=15' \
-H "Authorization: Bearer $JOMFORM_API_KEY"Expect HTTP 200, and a body containing "items". Without page this route answers with a plain array instead — the same rows, the older shape the dashboard reads.
That run is the whole flow: authenticate, create a form, publish it, read it back, read the records it produced.
Authentication
Send the key as a bearer token:
Authorization: Bearer $JOMFORM_API_KEY- A key belongs to a workspace (account), not to a person. Everything it reads and writes is that workspace's data.
- The dashboard also accepts a browser session cookie on these same routes. A script should use a key.
- The same key drives the MCP server. There is one credential for both surfaces — create it once and use it for
/v1/rest/*and for MCP (see the MCP tool reference). - A missing or revoked key is answered with 401 before any workspace is resolved, so a broken client fails loudly rather than reading data it should not.
Scopes
A key carries a scope that decides what it may do at all, independently of who created it:
readonly— read only. It is refused on every write with 403, and also on the reads whose handler writes anyway — an editor flow that saves through a write gate, or a value minted on first use. Those are the 3 routes below; the list comes from the contract, so it cannot go stale:GET /v1/rest/forms/{id}/email-settings— Read a form's email overridesGET /v1/rest/forms/{id}/notifications— List a form's notification rulesGET /v1/rest/settings/webhook-key— Read the webhook signing key
readwrite— read and write.
Inside a ?workspace=<slug> scope there is a second, independent axis: your role in that workspace. A write needs both a readwrite key and the manage-or-admin role, and the payment-credential routes need admin. Scope is a property of the credential; role is a property of the person. The reference below states, per endpoint, which gate applies.
Errors
Failures use the HTTP status for the class and a JSON body that says what went wrong:
{ "error": "auth diperlukan" }- 400 — the request is invalid. The message is written for the caller (“nama dan price_sen wajib”, “tidak dijumpai”) rather than being a fixed catalogue, so it may change wording without a version bump.
- 401 — missing, unknown or revoked credential.
- 403 — the credential is not allowed to do this: a readonly key on a write, or no role in the
?workspace=you asked for. The body names the reason, and it always contains the wordscopewhen a scope is the cause. - 404 — no such object in your workspace.
- 409 — the request conflicts with the object's state. Publishing a payment form that has no products returns 409 with
requires_ack: true, because that form could never take money. Send{"acknowledge":true}to publish it anyway. - 429 — rate limited (see below).
- 500 — an internal error, with the detail logged server-side and never returned to you.
- 503 — the feature needs something this deployment does not have (no language model configured, no media storage, and so on). Retrying will not help.
Rate limits
Most of this API has no rate limit. Do not design a client around a uniform quota that does not exist. Exactly 7 of the 119 operations are throttled, and they are:
POST /v1/rest/chat— 60 messages per hour per user, burst 10 (Ask the in-app assistant one message)POST /v1/rest/chat/cancel— 30 confirmations per hour per user, burst 5 (Discard a write the assistant proposed)POST /v1/rest/chat/confirm— 30 confirmations per hour per user, burst 5 (Execute a write the assistant proposed)POST /v1/rest/forms/ai-generate— 5 per hour per IP, burst 3 (Draft a form config from a prompt (AI))POST /v1/rest/uploads/asset— 2 per second per IP, burst 6 (Upload a code asset (multipart: css/js/html/svg/woff2))POST /v1/rest/uploads/avatar— 2 per second per IP, burst 6 (Upload a profile avatar (multipart))POST /v1/rest/uploads/image— 2 per second per IP, burst 6 (Upload an image (multipart))
The upload and AI routes are limited per client IP. The in-app assistant is limited per user, counted inside the handler, and it is session-only. A throttled response is 429; the per-IP limiter also sends Retry-After: 30. Every other route — including the ones that write — is currently unmetered, so apply your own backoff and treat a 5xx as retryable rather than assuming the server will protect you from a runaway loop.
Traffic reaches the API through Cloudflare, which is where edge protections live. Any per-key quota would be a separate, deliberate addition; none is promised here.
No CORS — server-to-server only
This API is built for server-to-server calls. It sends no CORS headers at all: there is no Access-Control-Allow-Origin and no Access-Control-Allow-Methods, and a browser's preflight OPTIONS request is not answered with them. A fetch() from a page on another origin will therefore be blocked by the browser, and the request may not even be sent.
That is deliberate, not an oversight. Call the API from your own backend, where you can keep the key out of the browser: that is the only place it is safe, because an API key shipped in client-side JavaScript is a key you have published. If your front end needs data, have it call your server, and have your server call JomForm.
Endpoint reference
All 119 operations, generated from the OpenAPI contract. The Access column is the gate the API's code enforces:
- admin
- readwrite key; admin role in the scoped workspace
- read
- Any authenticated key (readonly or readwrite)
- session
- Browser session only — an API key gets 401
- write
- readwrite key; manage/admin role inside a ?workspace= scope Not every write route: GET /v1/rest/settings/webhook-key applies the key scope only and reads no
?workspace=role. Its row says so below.
Forms
/v1/rest/formsreadList forms
Every form with its slug, status, public URL and mode. mode is payment (default) or response.
/v1/rest/formswriteCreate a form
A blank slug is generated from the title and uniquified within the workspace. An explicit slug is used as given, and must be unique among the workspace's live forms: a slug another live form already serves is refused with 409 form.slugInUse (the same code and sentence a slug rename onto it answers). The form is created as a draft; publish it with POST /v1/rest/forms/{id}/publish.
/v1/rest/forms/ai-generatewriterate limited: 5 per hour per IP, burst 3Draft a form config from a prompt (AI)
This route calls a language model and is charged accordingly, which is why it — and only it among the form routes — is throttled. Response: A draft form config (blocks + theme) to review and then save. Rate limit: 5 per hour per IP, burst 3.
/v1/rest/forms/bulkwritePublish, draft or delete many forms at once
Up to 500 ids. A payment form with no products is skipped on publish and reported in needs_products rather than being published empty.
/v1/rest/forms/trashreadList the forms in the trash
Response: The account's trashed forms, each with the number of sales attached to it, so a caller can tell a restorable form from one that can never be destroyed.
/v1/rest/forms/{id}readRead one form
Includes the current config (blocks) and theme, so you can read, mutate the parts you want and send the whole config back.
/v1/rest/forms/{id}writeUpdate a form's title, config or theme
/v1/rest/forms/{id}writeDelete a form (soft delete)
/v1/rest/forms/{id}/publishwritePublish a form
The form goes live at its URL. A payment form with no products is refused with 409 and requires_ack=true, because it could never take money; send {"acknowledge":true} or ?acknowledge=1 to publish it anyway.
/v1/rest/forms/{id}/purgewriteDestroy a trashed form for good
Only a form already in the trash can be destroyed. The body must repeat the form's own slug as confirm_slug; a mismatch is refused with 400. A form that has ever been sold is refused with 409 and sales_count, because sales history is never destroyed. Response: status=purged and the form id.
/v1/rest/forms/{id}/restorewriteRestore a form from the trash
Restoring touches no CASCADE. If another form has taken the trashed form's slug in the meantime the restore is refused with 409 — change that form's slug first, then restore. Response: status=restored and the form id.
/v1/rest/forms/{id}/statuswriteSet a form's status (draft | private | published)
status=private also sets an access password if one is supplied (hashed, never returned).
Form settings
/v1/rest/forms/{id}/confirmationsreadRead a form's confirmations
/v1/rest/forms/{id}/confirmationswriteSet a form's confirmations
/v1/rest/forms/{id}/email-settingswriteRead a form's email overrides
Refused for a readonly key: this route is reached from the email settings editor and is gated as a write even though it only reads.
/v1/rest/forms/{id}/email-settingswriteSet a form's email overrides
/v1/rest/forms/{id}/email-settingswriteClear a form's email overrides (inherit the workspace again)
/v1/rest/forms/{id}/menureadRead a form's restaurant-menu configuration
/v1/rest/forms/{id}/menuwriteSet a form's restaurant-menu configuration
/v1/rest/forms/{id}/notificationswriteList a form's notification rules
Gated as a write, like the email-settings read above.
/v1/rest/forms/{id}/notificationswriteCreate a notification rule
/v1/rest/forms/{id}/notifications/{nid}writeUpdate a notification rule
/v1/rest/forms/{id}/notifications/{nid}writeDelete a notification rule
/v1/rest/forms/{id}/restrictionswriteSet a form's open/close window and entry limit
/v1/rest/forms/{id}/settingswriteSet per-form display settings
/v1/rest/forms/{id}/trackingwriteSet per-form analytics/tracking
Submissions
/v1/rest/forms/{id}/submissionsreadList a response form's submissions
Response-mode forms store responses, not sales, so this is how you read them. Supports ?page= and ?limit= (max 100).
/v1/rest/forms/{id}/submissions/countreadCount a response form's submissions
Products
/v1/rest/productsreadList the workspace's products
Response: The workspace's live products, each with its variations.
/v1/rest/productswriteCreate a product
Money is integer sen. price_sen is required and may not be negative; a missing or null price_sen is refused rather than stored as 0. For a subscription set type=subscription and billing_period=month|year. Image URLs must be https://.
/v1/rest/products/bulkwriteArchive many products at once
Action-based bulk archive (active=false). Up to 500 ids per call.
/v1/rest/products/trashreadList the products in the trash
Each row also carries deleted_at (RFC3339). can_purge is false when the product has a sale or a subscription, which is exactly what DELETE .../purge would refuse with 409. Response: The workspace's trashed products, each with its sales_count, subscription_count and can_purge, so a caller can tell a restorable product from one that can never be destroyed.
/v1/rest/products/{id}readRead one product
/v1/rest/products/{id}writeUpdate a product
Partial update: omitted fields keep their current value. variations is replaced wholesale — send the full set, or [] to turn a variable product back into a simple one.
/v1/rest/products/{id}writeMove a product to the trash
This moves the product to the bin — it is not the same as taking it off sale. To take a product off sale and leave it in the catalogue, PUT {"active":false} instead; a trashed product is invisible to every other read until it is restored.
/v1/rest/products/{id}/purgewriteDestroy a trashed product for good
Only a product already in the trash can be destroyed; a live product is a 404. The body must repeat the product's own name as confirm_name; a mismatch is refused with 400. A product that has ever been sold is refused with 409 and sales_count (sales history is never destroyed), and one a subscription still points at is refused with 409 and subscription_count. Response: status=purged, the product id and its name.
/v1/rest/products/{id}/restorewriteRestore a product from the trash
Restoring touches no CASCADE and destroys nothing. Unlike a form restore there is no slug to collide with — products have no unique name — so this only fails when the product is not in the trash (404). Response: status=restored and the product id.
Sales
/v1/rest/email-deliveriesreadList email delivery events
/v1/rest/salesreadList sales and responses
Without a form filter this is the whole record set: sales AND response-mode submissions, merged newest first. Filters: ?form_id= ?status= ?from= ?to= ?q= ?page= ?limit=. Omit ?page= for the legacy plain-array shape.
/v1/rest/sales/bulk-capturewriteCapture many skip-capture authorisations
/v1/rest/sales/export.csvreadExport sales/responses as CSV
Response: A text/csv body with the same rows and filters as the list.
/v1/rest/sales/{ref}/capturewriteCapture a skip-capture authorisation
Omit amount_sen to capture the full authorisation; send it to capture part of it.
/v1/rest/sales/{ref}/confirmwriteConfirm a sale (for example a bank transfer)
/v1/rest/sales/{ref}/email-statusreadRead a sale's receipt email timeline
/v1/rest/sales/{ref}/refundwriteRefund a paid sale, in full or in part
A partial refund keeps the sale paid with refunded_sen set and outstanding_sen still refundable; only reaching the total marks it refunded. An amount that would over-refund is refused.
/v1/rest/sales/{ref}/releasewriteRelease (void) a skip-capture authorisation
Subscriptions
/v1/rest/subscriptionsreadList subscriptions
/v1/rest/subscriptions/{id}/cancelwriteCancel a subscription
Cancelling does not revoke the customer's card token; the subscription can be revived later.
/v1/rest/subscriptions/{id}/retrywriteRetry a past-due subscription now
/v1/rest/subscriptions/{id}/update-cardwriteRe-tokenise a subscription's card
Payments (CHIP)
/v1/rest/forms/{id}/chip-feedsreadRead a form's CHIP feeds
/v1/rest/forms/{id}/chip-feedsadminSet a form's CHIP feeds
Admin only: these feeds carry payment credentials.
/v1/rest/forms/{id}/chip-overrideadminOverride a form's CHIP credentials
/v1/rest/forms/{id}/chip-overrideadminClear a form's CHIP credential override
/v1/rest/settings/chipreadRead whether CHIP is configured
/v1/rest/settings/chipadminSet the workspace's CHIP credentials
/v1/rest/settings/chipadminClear the workspace's CHIP credentials
Webhooks
/v1/rest/webhooksreadList outbound webhooks
/v1/rest/webhookswriteCreate an outbound webhook
/v1/rest/webhooks/{id}writeUpdate an outbound webhook
/v1/rest/webhooks/{id}writeDelete an outbound webhook
/v1/rest/webhooks/{id}/deliveriesreadList a webhook's recent deliveries
/v1/rest/webhooks/{id}/deliveries/{deliveryId}/retrywriteRetry a failed delivery
/v1/rest/webhooks/{id}/retries/{retryId}/retrywriteRetry a dead-lettered delivery
Domains
/v1/rest/domainsreadList custom domains
/v1/rest/domainswriteAdd a custom domain
/v1/rest/domains/{id}writeDelete a custom domain
/v1/rest/domains/{id}/auto-cnamewriteCreate the CNAME through the linked Cloudflare account
/v1/rest/domains/{id}/custom-certwriteUpload a custom TLS certificate
/v1/rest/domains/{id}/tls-emailwriteSet the ACME contact email for a domain
/v1/rest/domains/{id}/tls-modewriteSet a domain's TLS mode
/v1/rest/domains/{id}/verifywriteVerify a custom domain's DNS
Media
/v1/rest/mediareadList uploaded media
/v1/rest/media/trashreadList the media in the trash
object_present reports whether the bytes are still in the store. A row can be in the bin while the object is already gone (removed by an operator or a bucket lifecycle rule), which is why a restore checks rather than assumes. Response: The workspace's trashed media, each with deleted_at (RFC3339) and object_present.
/v1/rest/media/{id}writeMove a media item to the trash
This marks the row trashed. The object itself is NOT removed from storage and the bytes keep counting against the workspace's 1GB quota: the bin holds real storage, so it is not a free tier. Destroy the object for good with DELETE /v1/rest/media/{id}/purge, which is also where the quota is released.
/v1/rest/media/{id}/purgewriteDestroy a trashed media item for good
Only a media row already in the trash can be destroyed; a live row is a 404. The object and its derived variants (sized images, PDF preview) are removed from the store and the bytes are released from the 1GB quota — this is the only path that releases them. Response: status=purged, the media id and the object key that was removed.
/v1/rest/media/{id}/restorewriteRestore a media item from the trash
Clears deleted_at, so the same URL works again — the bytes were never removed. If the object is no longer in the store the restore is refused with 409 and nothing is changed, because restoring would put a broken URL back in the library. Quota is untouched: the bytes were never released on soft delete. Response: status=restored, the media id and its (unchanged) public URL.
/v1/rest/uploads/assetwriterate limited: 2 per second per IP, burst 6Upload a code asset (multipart: css/js/html/svg/woff2)
Merchant-only. A code asset is scanned before it is served. Response: The stored asset, which stays unserved until its scan passes. Rate limit: 2 per second per IP, burst 6.
/v1/rest/uploads/avatarwriterate limited: 2 per second per IP, burst 6Upload a profile avatar (multipart)
Response: The stored avatar with its public https URL. Rate limit: 2 per second per IP, burst 6.
/v1/rest/uploads/imagewriterate limited: 2 per second per IP, burst 6Upload an image (multipart)
multipart/form-data with the file in file. Send the bytes, not JSON. Response: The stored media item with its public https URL. Rate limit: 2 per second per IP, burst 6.
Templates
/v1/rest/templatesreadList the workspace's own templates
/v1/rest/templateswriteCreate a template
/v1/rest/templates/{id}readRead a template
/v1/rest/templates/{id}writeUpdate a template
/v1/rest/templates/{id}writeDelete a template
/v1/rest/templates/{id}/usewriteClone a template into a new draft form
Analytics
/v1/rest/analytics/formsreadPer-form entry counts and money
Each row counts exactly one table — a response form's responses, or a payment form's sales — so the two are never summed.
/v1/rest/analytics/productsreadPer-product sales totals
/v1/rest/eventsreadList the account's audit/behaviour events
/v1/rest/events/breakdownreadBreak events down by name
/v1/rest/events/countreadCount events
/v1/rest/events/streamreadStream this workspace's events (SSE)
Server-sent events. The stream is account-scoped by the authenticating credential, so it can never carry another tenant's events. Response: A text/event-stream of sale.paid, sale.failed, response.submitted and subscription.charged events for the authenticated account.
Spam review
/v1/rest/spam/salesreadList spam-flagged sales
/v1/rest/spam/sales/{ref}/unspamwriteUn-flag a sale (false positive)
/v1/rest/spam/submissionsreadList spam-flagged submissions
/v1/rest/spam/submissions/{id}/unspamwriteUn-flag a submission (false positive)
Settings
/v1/rest/currency/ratesreadRead cached exchange rates (BNM)
/v1/rest/settings/custom-domainreadRead the workspace's custom domain
/v1/rest/settings/custom-domainwriteSet the workspace's custom domain
/v1/rest/settings/custom-domainwriteClear the workspace's custom domain
/v1/rest/settings/einvoicereadRead e-Invoice (LHDN) settings
/v1/rest/settings/einvoicewriteSet e-Invoice (LHDN) settings
/v1/rest/settings/trackingreadRead workspace tracking/analytics
/v1/rest/settings/trackingwriteSet workspace tracking/analytics
/v1/rest/settings/webhook-keywrite (key scope only — no ?workspace= role)Read the webhook signing key
Refused for a readonly key: the account's Ed25519 keypair is generated on first use, so the FIRST call on a workspace writes and this read is gated as a write. Once a keypair exists the same readonly key is served — the gate is state-dependent, and Access states the strongest one the handler applies. The key scope is the only gate here: the handler does not read a ?workspace= role.
API keys
/v1/rest/keysreadList API keys (never the secret)
/v1/rest/keyswriteCreate an API key
Returns the raw key exactly once — store it then. Scopes default to ["readonly"]; the supported values are readonly and readwrite. The same key authenticates MCP.
/v1/rest/keys/{id}writeRevoke an API key
Account
/v1/rest/dashboard-bundlereadOne call for the whole dashboard payload
Response: An object with me, workspaces, chip_status, keys, domains, webhooks, social, einvoice, products, summary, sales, form_analytics and product_analytics.
/v1/rest/summaryreadWorkspace totals (sales, revenue, counts)
Profile
/v1/rest/profile/avatarsessionSet the signed-in user's avatar URL
The URL must point at JomForm-hosted media; arbitrary external URLs are refused.
Feedback
/v1/rest/feedbacksessionList your feedback threads
/v1/rest/feedbacksessionFile a feedback thread
/v1/rest/feedback/{id}sessionRead one of your feedback threads
/v1/rest/feedback/{id}/messagessessionReply on one of your feedback threads
In-app assistant
/v1/rest/chatsessionReport whether the in-app assistant is available
/v1/rest/chatsessionrate limited: 60 messages per hour per user, burst 10Ask the in-app assistant one message
Session-only, and read-only by construction: the assistant's tool calls run under a readonly identity, so nothing typed into the box can write. The limit is enforced in the handler, keyed on the user rather than the IP. Response: The assistant's reply, the tool steps it took, and whether it filed feedback. Rate limit: 60 messages per hour per user, burst 10.
/v1/rest/chat/cancelsessionrate limited: 30 confirmations per hour per user, burst 5Discard a write the assistant proposed
Same request shape as confirm (token + args_hash), and it shares confirm's bucket. Withdrawing a proposal executes nothing. Response: status and the tool whose proposal was discarded. Rate limit: 30 confirmations per hour per user, burst 5.
/v1/rest/chat/confirmsessionrate limited: 30 confirmations per hour per user, burst 5Execute a write the assistant proposed
The only place a proposed write is executed. The body carries the opaque single-use token the browser holds and the args_hash the card showed; there is no confirm flag and nothing the caller can set to make the write happen, because the token IS the authorisation. A model cannot reach this endpoint — it is never given the token. Response: status, the tool that ran, and its result. Rate limit: 30 confirmations per hour per user, burst 5.
Versioning & changelog
Everything here lives under /v1, and /v1 is stable: existing endpoints keep their paths, their request fields and their response field names. Additive changes — a new endpoint, a new optional field, a new event type — may land under /v1 at any time, so tolerate unknown fields rather than rejecting a response that has grown one. A change that would break an existing integration means a new prefix (/v2), with the old one kept running alongside it.
Error message wording is not covered by that promise — match on the status code (and on the stable code field where one is returned, such as requires_ack), not on the text.
Changes to this contract show up in two places you can watch: the published openapi.json (which is regenerated with every route change, and fails the test suite when it is out of date) and the release notes for each version tag.
Building and testing against the API
There is no separate sandbox or test key. A key acts on your real workspace, so an integration test that runs against production data is an integration test that may email a customer. Instead, build against a workspace you are willing to throw away: create one for the integration (the dashboard's workspace switcher does this in a click, or POST /v1/workspaces), point its CHIP credentials at CHIP's own sandbox if you are exercising payments, and create a key scoped to that workspace. When you are done, delete the workspace rather than leaving it to rot — a throwaway workspace you can delete is what makes testing without a sandbox safe.
Use readonly for anything that only reads. It is the cheapest safety net in this API: a readonly key cannot damage real data even if your test harness has a bug.