Developer reference
Webhooks
Receive each completed form in your own system as it is submitted. Written for whoever is wiring up the endpoint — one page, no SDK, no account needed to read it.
Setting it up
- Sign in and open Delivery in the sidebar. Webhooks are part of the Elite plan.
- Enter your HTTPS endpoint and save. You will be shown a signing secret once — copy it then; it is never displayed again. Saving a new URL issues a new secret.
- Choose Test endpoint. Nothing is delivered until this passes.
Your endpoint must be reachable over HTTPS on a public hostname. Redirects are not followed, and URLs containing a username or password are rejected — use the signature to authenticate us instead.
The verification challenge
When you choose Test endpoint, we POST a signed body containing a one-time value. Echo it back to prove the endpoint is yours.
POST /your-endpoint
Content-Type: application/json
Careforms-Signature: t=1755000000,v1=5f3a…
{"type":"careforms.webhook.verify","challenge":"Xk92mQ7pLd0AbC1e"}Answer 2xx with the same value, either way round:
{"challenge":"Xk92mQ7pLd0AbC1e"} ← preferred
Xk92mQ7pLd0AbC1e ← also acceptedA success status that does not echo the value fails the test on purpose: answering is not the same as owning.
The test does not check your signature verification. It proves your endpoint is reachable and yours, and nothing more — so an endpoint that ignores the signature entirely will pass it. The challenge POST is signed exactly like a real submission, which makes it the easiest thing to develop against: verify it, and log what you computed against the header we sent.
Verifying the signature
Every request carries a Careforms-Signature header. It uses the same scheme as Stripe, so an existing verifier usually needs only a new secret and header name.
Careforms-Signature: t=1755000000,v1=<hex>
v1 = HMAC-SHA256(secret, "{t}.{raw request body}")The key is the signing secret exactly as we gave it to you — the 64-character string, used as-is. It looks like hex, and it is, but do not decode it first: pass the string straight to your HMAC function, the same way you would a Stripe whsec_ secret. This is the one part of the scheme you cannot check against a captured request, so it is worth getting right before you write anything else.
Compute it over the raw body bytes, before any JSON parsing — re-serialising changes the whitespace and the signature will not match. Compare with a constant-time function, and reject anything whose t is more than a few minutes old so a captured request cannot be replayed later.
// Node
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('='))
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}Delivery guarantees
At most once. We attempt delivery while the client is still waiting on their confirmation screen: one attempt, plus one retry if the first fails, inside a few seconds. Then we stop, permanently, for that submission.
There is no queue and no later retry, and this is a property of the product rather than a limitation we intend to remove. Careforms discards a client's answers at the end of the request that received them, so there is no stored copy to retry from — that is the same design that lets us say we never hold your clients' data.
The consequences to design around:
- If your endpoint is down, that submission reaches the provider by email only. It will not turn up later.
- Treat the email as the system of record and the webhook as an accelerator, not the other way round.
- A 4xx is treated as a rejection and not retried. A 5xx, a timeout or a connection failure gets the single retry.
- A 3xx is also a rejection and is not retried. We do not follow redirects — a redirect would deliver a signed submission to a host you never configured with us — so a moved endpoint is a delivery failure rather than a detour. Change the URL in your Delivery settings instead of redirecting from the old one.
- Deduplicate on submission_id. The retry above is one way to receive the same submission twice; it is not the only one. A client whose connection drops mid-submit and who presses Submit again sends the same submission_id, and if the first request is still in flight both can reach you. Do not skip the deduplication on the grounds that your endpoint always answers 2xx.
- Answer quickly and do the work afterwards. Anything slower than a few seconds is a timeout, and your provider's client is watching a spinner in the meantime.
The outcome, the number of attempts we made and the status your endpoint returned are recorded against the submission, so the provider can see in their admin panel whether you took it. Their Delivery screen shows whether the most recent submission reached you, so an endpoint that has stopped working is visible without opening anything.
The submission body
Byte-identical to the JSON file a provider can have attached to their email, so there is one document to work against.
{
"event_id": "3f1c…",
"submission_id": "9b0e…",
"tenant_id": "8a72…",
"form": "intake",
"link_token": "kR3mQp7xTz",
"link_reference": "Jane Doe — Aug intake",
"recipe_version": null,
"pack_version": "32f5a6007bd1",
"server": {
"timestamp_utc": "2026-08-14T01:02:03.000Z",
"country": "AU",
"ray": "8f2c…",
"user_agent": "Mozilla/5.0 …"
},
"client": {
"full_name": "Jane Citizen",
"email": "jane@example.com",
"form_filler": "client",
"timestamp": "2026-08-14T11:02:01+10:00",
"timezone": "Australia/Melbourne"
},
"hashes": {
"row_sha256": "…",
"client_intake_sha256": "…"
},
"payload": { "clientDetails": { "…": "…" }, "questions": {}, "consent": {} }
}| Field | Notes |
|---|---|
| submission_id | Your idempotency key. Stable across the one permitted retry. |
| event_id | Our identifier for the delivery record the provider sees. |
| tenant_id | The provider's account. One endpoint belongs to one account, so this is constant for you. |
| form | Which form this was: "enquiry", "intake" or "pre-service". An enquiry is a short public form anyone can fill in from a page the provider publishes; the other two are sent to a named person as a link. Branch on this rather than on whether link_token is null. |
| link_token | The single-use token the form was opened with. Null on an enquiry, which has no link and never had one. Present and null rather than absent, so the key is on every record. |
| link_reference | The provider's own private label for the link. Null if they didn't set one, and always null on an enquiry. |
| pack_version | Identifies the question library build this form was drawn from, and changes whenever we change that library. It is not a fingerprint of the form itself: which questions a particular provider's form actually contained also depends on their own configuration — sector, services, and any questions they switched off — which is not part of this value and is not in this record. Record it to know which library build a submission was collected against; ask the provider if you need the form. |
| recipe_version | The older question-set identifier, kept for forms issued before 23 August 2026. Null on every submission since, and deliberately so: exactly one of these two describes the content a record was collected against, and a version that is plausible but wrong is worse than one that is absent. |
| server | Coarse request metadata we recorded: UTC timestamp, country, Cloudflare ray ID and user agent. No IP address. |
| client | The submitter's name, email, whether the client or a representative filled it in, and their own device clock and timezone. |
| client.email | Optional — the form does not require it. |
| hashes | SHA-256 of the delivery record and of the answers. |
| hashes.client_intake_sha256 | SHA-256 of the canonicalised payload. Also printed on the PDF, so a provider can prove the two match. There is no PDF on an enquiry; the hash is still here. |
| payload | The answers, nested by form step. Its shape follows the provider's configuration and which form it was — treat unknown keys as additive and do not assume any given key is present. |
Handling personal information
The payload contains identifying details, contact details and, on an intake or first-visit form, health-adjacent information about a real person. Once it reaches your endpoint, it is held under the provider's obligations, not ours — including their retention obligations under the NDIS Practice Standards, and the Australian Privacy Principles.
An "enquiry" record is a different kind of thing and worth handling as one. The person who filled it in has no relationship with the provider, has not become a client, and may be about to be told the provider cannot help them. Nothing about our delivery changes, but the retention and marketing decisions your system makes about that record should not be the ones it makes about a client's intake.
If your endpoint is hosted outside Australia, receiving this data is an overseas disclosure under APP 8, which the provider is responsible for disclosing in their own privacy notice. Our handling is described in our privacy policy.
Questions
Email support@careforms.com.au. Include the submission_id if you are asking about a specific delivery — we can see whether it was attempted and what your endpoint returned, but we cannot see or resend its contents.