Drive Forms API — POST /forms/submit
Integration reference for POST /forms/submit.
1. Overview
| Method and path | POST /forms/submit |
| Content type | application/json |
| Body | A single flat JSON object |
| Authentication | None. No API key, no bearer token |
| Always required | vendorId, and turnstileToken on a real submission |
| Required by the default destination | email |
| Test mode | ?dryRun=1 — validates and previews, delivers nothing |
| Delivering today | insider only |
| Environment | Endpoint |
|---|---|
| Development | https://dev-api.drivemustang.com.au/forms/submit |
| Staging | https://staging-api.drivemustang.com.au/forms/submit |
| Production | https://api.drive.com.au/forms/submit |
Same contract and same validation in all three.
Smoke test
The minimum call that reaches the endpoint. ?dryRun=1 skips Turnstile and delivers nothing, so
this is the first thing to run against a new integration.
Flat request body sent by the integrating form:
curl -sS -X POST 'https://dev-api.drivemustang.com.au/forms/submit?dryRun=1' \
-H 'Content-Type: application/json' \
-d '{"vendorId":"drive","email":"jane.doe@example.com"}'Dry-run response containing the generated Insider payload:
{
"success": true,
"status": 200,
"message": "OK",
"destinations": ["insider"],
"contact": {
"identifiers": { "email": "jane.doe@example.com" },
"events": [
{
"event_name": "apig_form_submit",
"timestamp": "2026-08-31T04:12:03.512Z",
"event_params": {}
}
]
}
}Add your own fields one at a time from there and watch them appear under
contact.events[0].event_params.custom.
2. Contract structure
The body is one flat object. Three groups of keys live in it, and they behave differently.
{
"vendorId": "drive",
"turnstileToken": "<TOKEN_FROM_WIDGET_CALLBACK>",
"destinations": ["insider"],
"email": "jane.doe@example.com",
"mobile": "0400 000 000",
"uuid": "6f1e2b4c-0d3a-4f5e-8a9b-1c2d3e4f5a6b",
"name": "Jane",
"surname": "Doe",
"email_optin": true,
"ca_string_1": "SUV",
"ca_date_1": "2026-08-25T14:30:00+10:00"
}Control keys — vendorId, turnstileToken, destinations. Read by the API, never forwarded.
Identifiers — email, mobile, uuid. These resolve which Insider profile the submission
attaches to. They are not event parameters, and mobile is the one key whose name changes:
| You send | Sent as | Transform |
|---|---|---|
email | identifiers.email | Lower-cased for you |
mobile | identifiers.phone_number | Normalised to E.164 |
uuid | identifiers.uuid | None |
Form parameters — everything else, listed in Section 6.
Each is forwarded under exactly the name you posted it under, inside event_params.custom. There is
no name translation and no numeric field id — the field name is the identifier.
The Insider payload this produces, which ?dryRun=1 returns verbatim:
{
"identifiers": {
"email": "jane.doe@example.com",
"phone_number": "+61400000000",
"uuid": "6f1e2b4c-0d3a-4f5e-8a9b-1c2d3e4f5a6b"
},
"events": [
{
"event_name": "apig_form_submit",
"timestamp": "2026-08-31T04:12:03.512Z",
"event_params": {
"custom": {
"name": "Jane",
"surname": "Doe",
"email_optin": true,
"ca_string_1": "SUV",
"ca_date_1": "2026-08-25T14:30:00+10:00"
}
}
}
]
}- An empty string is treated as absent. Every key whose value is
""is dropped before anything else runs, so an untouched HTML input is not sent rather than sent empty. Anullpasses validation and is stripped before sending. Post every field your form knows about, filled in or not. - An unrecognised field name is dropped silently, not rejected. You get a
200and the value goes nowhere. Use?dryRun=1to confirm your names land.
3. Access model
No API key and no bearer token. Three things stand in for a credential.
A registered vendorId, required on every request. An unregistered value is
400 Unknown vendorId. drive and refinery are registered today; ask the Drive platform team to
register a new one, along with the hostnames its forms run on, before you start building.
A Cloudflare Turnstile token — see Section 4.
Origin allow-listing, which decides whether a browser may read the response:
- The
OPTIONSpreflight is permissive. The real response carriesAccess-Control-Allow-Originonly when the request'sOriginmatches a hostname registered for yourvendorId. - https only, except
localhostand127.0.0.1, which work over plain http for local development. - A registered hostname can be a wildcard, for preview or branch deployments whose hostname
cannot be listed ahead of time.
*.drive.com.aumatcheswww.drive.com.auandpreview-42.beta.drive.com.au, but notdrive.com.auon its own — register the apex as its own entry if you need it. - An unmatched origin does not block the request. It is still processed; your page's JavaScript just cannot read the reply.
4. Cloudflare Turnstile
Turnstile is required on every real submission. There is no way to opt out of it.
4.1 How it works
- You render a Turnstile widget on your form page using the sitekey we give you, configured for manual execution. Ours is an invisible widget: nothing is displayed, there is no checkbox and the visitor never interacts with it. You still need a container element for it to render into.
- When the visitor submits the form, call
turnstile.execute()to run the check and generate a fresh token. - Cloudflare passes the token to the success callback. Immediately post the form with that token in
the request body as
turnstileToken. - We verify it server-side with Cloudflare, and check the hostname Cloudflare reports it was issued
on against the allow-list registered for your
vendorId.
Rendering the widget and getting the token is standard Cloudflare integration — see
client-side rendering
and widget types. Use Cloudflare's
execute execution mode so the token is generated as part of submission rather than when the form
page first loads. The widget type is set on the sitekey, so there is nothing to configure at your end
for it to be invisible.
Two things differ from Cloudflare's own examples: the token goes in the JSON body as
turnstileToken rather than as the cf-turnstile-response form field, and the form request must wait
for the success callback rather than being sent by the original submit event. Handle the error and
timeout callbacks yourself.
The token comes from Cloudflare's browser-side Turnstile widget at submission time. Send the value
passed to its success callback as turnstileToken. Replace <TOKEN_FROM_WIDGET_CALLBACK> below with
that actual value; do not send the placeholder, the public sitekey, or the server-side secret key.
curl -sS -X POST 'https://dev-api.drivemustang.com.au/forms/submit' \
-H 'Content-Type: application/json' \
-d '{
"vendorId": "drive",
"turnstileToken": "<TOKEN_FROM_WIDGET_CALLBACK>",
"email": "jane.doe@example.com",
"name": "Jane",
"email_optin": true
}'The value replacing <TOKEN_FROM_WIDGET_CALLBACK> must be fresh, unused, and issued on a hostname
registered for the supplied vendorId.
4.2 What you need from us
- The Turnstile sitekey for your environment. It is public and belongs in your page; the matching secret is server-side and you never see it.
- Your form's hostnames registered against your
vendorId, as well as against the sitekey. Both lists are checked — see 4.4.
4.3 Token rules
- A token is single-use. Reset the widget after every submission attempt, or the first
submission works and every one after it is a
403. - A token expires after about five minutes. Generate it as part of the submission process, after the visitor has finished filling out the form, rather than when the page first loads. If the form cannot be sent immediately, execute Turnstile again and use the new token.
4.4 The hostname check
Verification is two checks, not one. A token that Cloudflare accepts still returns a 403 if the
hostname it was issued on was never registered against your vendorId.
A rejected token and an unregistered hostname return an identical response:
{ "success": false, "status": 403, "message": "Turnstile verification failed" }If you get a 403 you do not expect, check both lists with the Drive platform team before debugging
your widget.
4.5 When verification runs
- On every real submission, in any environment where the server has a Turnstile secret configured.
- Never on
?dryRun=1, so you can build and test a payload before the widget is wired up. - After
vendorIdanddestinationsare read and the routing checks in section 7 pass, but before any other field is read.
A 500 at this stage rather than a 403 means the server could not reach its own secret or
Cloudflare. Your token was never inspected.
5. Destinations
destinations is an optional array naming where the submission goes. It accepts insider, leads
and email.
| What you send | Result |
|---|---|
| Omitted | Defaults to ["insider"] |
["leads"] | Exactly that. insider is not added back in |
["insider","insider"] | De-duplicated |
[] | 400 Unknown destination |
["salesforce"] | 400 Unknown destination |
"leads" (a string) | 400 Unknown destination — it must be an array |
Every declared destination's schema runs and all of them must pass. A failure anywhere fails the whole submission and nothing is delivered.
6. Accepted parameters and their types
Every type below is enforced. Values are checked strictly and nothing is coerced — see 6.8. Send JSON types, not form-encoded strings.
Every name below is forwarded under exactly that name. A name not below is dropped silently.
6.1 Control keys
| Field | Type | Notes |
|---|---|---|
vendorId | string | Required. A registered vendor |
turnstileToken | string | Required on a real submission; ignored on ?dryRun=1 |
destinations | array of string | Non-empty array of insider / leads / email. Optional; defaults to ["insider"] |
6.2 Identifiers
| Field | Type | Notes |
|---|---|---|
email | string (email) | Mandatory for insider. Lower-cased for you |
mobile | string | Optional. See 6.9 |
uuid | string | Optional CDP profile id |
6.3 Attribution slots
Four teams get three string, three boolean and one date slot each; Marketplace Dealer gets ten string slots.
| Field | Type | Notes |
|---|---|---|
ca_string_1 – ca_string_3 | string | Max 512 characters |
ca_boolean_1 – ca_boolean_3 | boolean | A real true/false, not "true" |
ca_date_1 | string (ISO 8601) | See 6.10 |
ed_string_1 – ed_string_3 | string | Max 512 characters |
ed_boolean_1 – ed_boolean_3 | boolean | |
ed_date_1 | string (ISO 8601) | |
mc_string_1 – mc_string_3 | string | Max 512 characters |
mc_boolean_1 – mc_boolean_3 | boolean | |
mc_date_1 | string (ISO 8601) | |
ref_string_1 – ref_string_3 | string | Max 512 characters |
ref_boolean_1 – ref_boolean_3 | boolean | |
ref_date_1 | string (ISO 8601) | |
md_string_1 – md_string_10 | string | Max 512 characters |
Slot names are fixed. There is no ca_string_9, and no way to request a different slot at
submission time. A mistyped slot name is dropped silently like any other unrecognised field.
6.4 Consent and opt-in
Booleans, except date_optin.
| Field | Type | Notes |
|---|---|---|
email_optin | boolean | |
sms_optin | boolean | |
sms_opt_out_user | boolean | |
comms_optin_e1 – comms_optin_e11 | boolean | 11 numbered slots |
comms_optin_marketing_drive | boolean | |
comms_optin_marketing_partner | boolean | |
date_optin | string (ISO 8601 datetime) | The date someone opted in, not whether they did |
Consent submitted here travels as an event parameter only. It does not change consent on the profile.
6.5 Everything else
site_section additionally has its value constrained when leads is declared — see
6.6. Everywhere else it is a free string.
Contact and profile
| Field | Type |
|---|---|
name, surname | string |
company, department, designation | string |
contact_id, contact_source | string |
crm_subscription_id, request_id | string |
family_uuid | string |
custom_segment_id | array of number |
pn | string (promotion name) |
up | number |
Location
| Field | Type |
|---|---|
city, country, country_code, region, location_state | string |
Site and campaign context
| Field | Type |
|---|---|
site_section | string (constrained when leads is declared — see 6.6) |
site_type, device_type | string |
campaign, medium, survey_url | string |
enquiry_type, enquiry_origin | string |
enquiry_postcode | number |
last_enquiry_date | string (ISO 8601 datetime) |
has_transacted_carsforsale | boolean |
has_transacted_finance | boolean |
has_transacted_newcar | boolean |
has_transacted_sellmycar | boolean |
Vehicle
| Field | Type |
|---|---|
car_make, car_model, body_type | string |
vehicle_make, vehicle_make_uuid | string |
vehicle_model, vehicle_model_uuid | string |
vehicle_variant, vehicle_variant_uuid | string |
vehicle_badge, vehicle_body_type, vehicle_condition, vehicle_drive_code | string |
vehicle_engine_description, vehicle_details | string |
vehicle_key, vehicle_uuid | string |
vehicle_main_image_url | string |
vehicle_registration_number, vin_number | string |
variant_long_description, variant_short_description | string |
vehicle_odometer_kms, vehicle_year | string (string despite the name) |
vehicle_price, vehicle_trade_max, vehicle_trade_min | number |
Dealer
| Field | Type |
|---|---|
dealer_name, dealer_address, dealer_suburb, dealer_state | string |
dealer_postcode | number |
6.6 The leads destination
Validated today, delivers nowhere yet. A real submission declaring leads returns 501;
?dryRun=1 validates it.
| Field | Type | Notes |
|---|---|---|
lead_first_name | string | Mandatory, non-empty |
lead_last_name | string | Mandatory, non-empty |
lead_email | string (email) | Mandatory. No fallback to the top-level email |
lead_phone | string | Mandatory, minimum 8 characters. A string, not a number. Normalised to E.164 like mobile; a value that does not parse as a valid Australian number is dropped from the Insider event |
lead_postcode | string | Mandatory. A real AU postcode: ^(?:(?:[2-8]\d|9[0-7]|0?[28]|0?9(?=09))(?:\d{2}))$ |
lead_type | string | Mandatory. One of new-car, finance, cars-for-sale, instant-offer, novated-lease |
lead_origin | string | Mandatory. One of website, livechat, unbounce, facebook, customer-service |
site_section | string | Value constrained when leads is declared. One of showrooms, reviews, cars-for-sale, whats-my-car-worth, other |
lead_id, lead_source, lead_url | string | Optional |
lead_state, lead_status, lead_suburb | string | Optional |
lead_enquiry_datetime | string (ISO 8601 datetime) | Optional |
lead_ip_address | string | Optional |
lead_preferred_contact_method | string | Optional |
lead_is_manually_allocated, lead_is_test | boolean | Optional |
The site_section pattern in force is
^(showrooms|reviews|cars-for-sale|whats-my-car-worth|lead-pages\|custom-form|other)$. The escaped
pipe makes the fifth alternative the single literal string lead-pages|custom-form. Neither
lead-pages nor custom-form is accepted on its own — use one of the five values in the table.
6.7 The email destination
Also validated, also not delivering yet.
| Field | Type | Notes |
|---|---|---|
eml_comments | string | Mandatory, non-empty. The enquiry body |
eml_lead_first_name | string | Optional |
Sender, recipients and subject are server-side configuration, not caller input.
6.8 Strict typing
Nothing is coerced:
| You send | Result |
|---|---|
"ca_boolean_1": true | Accepted |
"ca_boolean_1": "true" | Rejected. A string is not a boolean |
"ca_string_1": 42 | Rejected. A number is not a string |
"ca_string_1": ["a","b"] | Rejected. An array is not a string, and is not flattened |
"ca_string_1": "" | Accepted — dropped before validation, so the field is absent |
"ca_string_1": null | Accepted, then stripped before sending. Nothing is stored |
| A string slot over 512 characters | Rejected |
Convert a checkbox to a real boolean before posting, and a number field to a real number.
6.9 The mobile rule
Optional, but a value that is present is checked:
- It must be a string. A number (
61400000000) is rejected. - It may contain only
0–9, space,+,(,)and-. Anything else is a400—"???0400 000 123??"is rejected, not cleaned up. - It must parse to a valid Australian number. Premium-rate, shared-cost and toll-free are rejected.
- It is normalised to E.164 before sending:
0400 000 000→+61400000000,(02) 9999-1234→+61299991234.
null is accepted and dropped.
6.10 The date rule
The four date slots — ca_date_1, ed_date_1, mc_date_1, ref_date_1 — accept either shape:
- a bare date,
2026-08-25 - a full ISO 8601 timestamp,
2026-08-25T14:30:00+10:00or2026-08-25T00:00:00Z
Anything that is not a date is rejected, and so is a date that does not exist: 2026-02-30,
2026-13-01 and 29/08/2026 are all a 400.
The value is sent exactly as you wrote it. Nothing is reformatted and nothing is populated for you.
Send a full timestamp. Insider stores nothing for a bare YYYY-MM-DD and still answers 200,
so a date-only value is lost with no error anywhere. The bare shape is accepted so a native
<input type="date"> is not rejected, but if you want the value stored, send the instant you mean —
including for dates that are not visible user inputs.
date_optin, last_enquiry_date and lead_enquiry_datetime are not validated at all. Send full
timestamps there too.
7. Validation, in the order it runs
| # | Check | Failure |
|---|---|---|
| 1 | Content-Type, if sent, is application/json | 415 Unsupported content type |
| 2 | Body parses as a JSON object | 400 Invalid request payload |
| 3 | vendorId is a registered vendor | 400 Unknown vendorId |
| 4 | destinations, if present, is a non-empty array of known ids | 400 Unknown destination |
| 5 | Every declared destination is wired for delivery (real submissions only) | 501 Destination not yet available |
| 6 | Turnstile token verifies, and its hostname is allow-listed (real submissions only) | 403 Turnstile verification failed |
| 7 | Every declared destination's schema passes, date slots name a real day, and mobile, if present, parses as an AU number | 400 Validation failed |
| 8 | Insider accepts the data (real submissions only) | 400 Validation failed |
Routing is settled before verification: a submission that could never be delivered is refused on its routing alone, without spending a siteverify call.
Step 7's three checks are not sequential gates — they all run and their errors are merged into one report, so a submission can fail more than one at once.
Empty strings are stripped before step 3, so they never reach validation.
8. Responses
Every response carries success, status and message. Branch on success and status, never on
the message string.
| Status | Meaning |
|---|---|
200 | Accepted and delivered, or a ?dryRun=1 preview |
400 | Malformed JSON, unknown vendorId, bad destinations, or a schema failure |
403 | Turnstile verification failed, or its hostname is not allow-listed |
415 | Content-Type was sent and was not application/json |
500 | Failure on our side. Always the same generic message |
501 | A real submission named a destination that is not wired yet |
| other | An upstream failure at Insider; its status is mirrored back. See 8.5 |
8.1 Success
{ "success": true, "status": 200, "message": "Success" }8.2 Dry run
{
"success": true,
"status": 200,
"message": "OK",
"destinations": ["insider"],
"contact": {
"identifiers": { "email": "jane.doe@example.com", "phone_number": "+61400000000" },
"events": [
{
"event_name": "apig_form_submit",
"timestamp": "2026-08-31T04:12:03.512Z",
"event_params": { "custom": { "name": "Jane", "surname": "Doe", "email_optin": true } }
}
]
}
}contact is the exact body that would have been posted to Insider. When insider is not among the
declared destinations, contact is omitted and you get destinations alone.
8.3 Validation failure
{
"success": false,
"status": 400,
"message": "Validation failed",
"requestId": "c3a1e6f2-6b31-4e2a-9f7a-1a2b3c4d5e6f"
}No field names are returned. The detail is in our logs against that requestId — capture it and
quote it when you ask for help. Insider rejecting the data returns this same response, so a 400
does not tell you which layer rejected it.
The request-level failures — Invalid request payload, Unknown vendorId, Unknown destination —
carry their specific message and no requestId.
8.4 Turnstile and unwired destinations
{ "success": false, "status": 403, "message": "Turnstile verification failed" }{ "success": false, "status": 501, "message": "Destination not yet available" }Nothing is delivered on a 501, including to insider if it was declared alongside.
8.5 Upstream rejection
If Insider rejects the submitted data, you get the same 400 Validation failed as 8.3.
No reason is passed through: a rejection from Insider tells you no more than one from our own
schemas.
Any other upstream failure mirrors Insider's status with the generic message and a requestId:
{
"success": false,
"status": 502,
"message": "Unable to submit the form",
"requestId": "c3a1e6f2-6b31-4e2a-9f7a-1a2b3c4d5e6f"
}8.6 Our side failed
{ "success": false, "status": 500, "message": "Unable to submit the form" }Always this exact message.
8.7 Retries
There is no idempotency key and no de-duplication. Each accepted submission creates a new event.
- Safe to retry: a request that never got a response. Nothing was recorded.
- Do not retry unchanged:
400,403,501. Fix the request; for a403, reset the widget for a fresh token first. - Retry with backoff: a flat
500from us. - Investigate rather than auto-retry: a mirrored Insider status. The request reached Insider, so a blind retry risks a duplicate event for the same person.
8.8 Front-end checklist
- Disable the submit button while a request is in flight.
- Reset the Turnstile widget after every attempt.
- Show a generic "please check your details" message on a
400. There is no field-level reason to display. - Log the
requestIdwhere you can retrieve it later. - Never leave
?dryRun=1on in production. It never delivers.
9. Testing with ?dryRun=1
Validates the flat request payload and returns the nested contact payload that would have been sent to Insider, without sending it. Use it to confirm your field names land before the first real submission.
- Validation runs in full. An invalid payload still returns a
400in the same shape. - Turnstile is skipped — no live token needed.
- Nothing is delivered anywhere, including to Insider.
leadsandemailcan be dry-run today even though they do not deliver.- Only the literal string
1counts.?dryRun=true,?dryRun=0and anything else are treated as a real submission. Worth an explicit test.
10. Worked examples
Success
curl -sS -X POST 'https://api.drive.com.au/forms/submit' \
-H 'Content-Type: application/json' \
-d '{
"vendorId": "drive",
"turnstileToken": "<TOKEN_FROM_WIDGET_CALLBACK>",
"email": "jane.doe@example.com",
"mobile": "0400 000 000",
"name": "Jane",
"surname": "Doe",
"email_optin": true,
"sms_optin": false,
"site_section": "showrooms",
"ca_string_1": "SUV",
"ca_boolean_1": true,
"ca_date_1": "2026-08-25T14:30:00+10:00"
}'{ "success": true, "status": 200, "message": "Success" }Validation failure — a string in a boolean slot
curl -sS -X POST 'https://api.drive.com.au/forms/submit' \
-H 'Content-Type: application/json' \
-d '{
"vendorId": "drive",
"turnstileToken": "<TOKEN_FROM_WIDGET_CALLBACK>",
"email": "jane.doe@example.com",
"ca_boolean_1": "true"
}'{
"success": false,
"status": 400,
"message": "Validation failed",
"requestId": "c3a1e6f2-6b31-4e2a-9f7a-1a2b3c4d5e6f"
}403 — a replayed token, or one issued on an unregistered hostname
curl -sS -X POST 'https://api.drive.com.au/forms/submit' \
-H 'Content-Type: application/json' \
-d '{
"vendorId": "drive",
"turnstileToken": "0.already-used-token",
"email": "jane.doe@example.com"
}'{ "success": false, "status": 403, "message": "Turnstile verification failed" }501 — a real submission naming leads
curl -sS -X POST 'https://api.drive.com.au/forms/submit' \
-H 'Content-Type: application/json' \
-d '{
"vendorId": "drive",
"turnstileToken": "<TOKEN_FROM_WIDGET_CALLBACK>",
"destinations": ["insider", "leads"],
"email": "jane.doe@example.com",
"lead_first_name": "Jane",
"lead_last_name": "Doe",
"lead_email": "jane.doe@example.com",
"lead_phone": "0400000000",
"lead_postcode": "2000",
"lead_type": "new-car",
"lead_origin": "website",
"site_section": "showrooms"
}'{ "success": false, "status": 501, "message": "Destination not yet available" }Nothing was delivered, including to insider.
Dry run
curl -sS -X POST 'https://dev-api.drivemustang.com.au/forms/submit?dryRun=1' \
-H 'Content-Type: application/json' \
-d '{
"vendorId": "drive",
"email": "jane.doe@example.com",
"mobile": "(02) 9999-1234",
"name": "Jane",
"ca_string_1": "SUV",
"not_a_real_field": "dropped silently"
}'{
"success": true,
"status": 200,
"message": "OK",
"destinations": ["insider"],
"contact": {
"identifiers": { "email": "jane.doe@example.com", "phone_number": "+61299991234" },
"events": [
{
"event_name": "apig_form_submit",
"timestamp": "2026-08-31T04:12:03.512Z",
"event_params": { "custom": { "name": "Jane", "ca_string_1": "SUV" } }
}
]
}
}mobile normalised to E.164, and not_a_real_field gone without an error.
11. Known behaviours
Intentional, not bugs:
- A
400never names the failing field. Use therequestId. - A rejected token and an unregistered hostname return an identical
403. - An unrecognised field is dropped, not rejected. A typo fails silently — dry-run to catch it.
- A field on the accepted list is still not guaranteed to be stored. Insider discards a parameter it
does not recognise behind a
200. If a value does not appear, check that first. - A bare
YYYY-MM-DDis accepted and then stored as nothing. See 6.10. - Consent fields do not change consent on the profile.
- Submitted values never appear in a log line. Only field names do.