v2.4.2

Drive Forms API — POST /forms/submit

Integration reference for POST /forms/submit.


1. Overview

Method and pathPOST /forms/submit
Content typeapplication/json
BodyA single flat JSON object
AuthenticationNone. No API key, no bearer token
Always requiredvendorId, and turnstileToken on a real submission
Required by the default destinationemail
Test mode?dryRun=1 — validates and previews, delivers nothing
Delivering todayinsider only
EnvironmentEndpoint
Developmenthttps://dev-api.drivemustang.com.au/forms/submit
Staginghttps://staging-api.drivemustang.com.au/forms/submit
Productionhttps://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:

bash
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:

json
{
  "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.

json
{
  "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 sendSent asTransform
emailidentifiers.emailLower-cased for you
mobileidentifiers.phone_numberNormalised to E.164
uuididentifiers.uuidNone

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:

json
{
  "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. A null passes 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 200 and the value goes nowhere. Use ?dryRun=1 to 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 OPTIONS preflight is permissive. The real response carries Access-Control-Allow-Origin only when the request's Origin matches a hostname registered for your vendorId.
  • https only, except localhost and 127.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.au matches www.drive.com.au and preview-42.beta.drive.com.au, but not drive.com.au on 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

  1. 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.
  2. When the visitor submits the form, call turnstile.execute() to run the check and generate a fresh token.
  3. Cloudflare passes the token to the success callback. Immediately post the form with that token in the request body as turnstileToken.
  4. 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.

bash
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:

json
{ "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 vendorId and destinations are 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 sendResult
OmittedDefaults 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

FieldTypeNotes
vendorIdstringRequired. A registered vendor
turnstileTokenstringRequired on a real submission; ignored on ?dryRun=1
destinationsarray of stringNon-empty array of insider / leads / email. Optional; defaults to ["insider"]

6.2 Identifiers

FieldTypeNotes
emailstring (email)Mandatory for insider. Lower-cased for you
mobilestringOptional. See 6.9
uuidstringOptional 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.

FieldTypeNotes
ca_string_1 – ca_string_3stringMax 512 characters
ca_boolean_1 – ca_boolean_3booleanA real true/false, not "true"
ca_date_1string (ISO 8601)See 6.10
ed_string_1 – ed_string_3stringMax 512 characters
ed_boolean_1 – ed_boolean_3boolean
ed_date_1string (ISO 8601)
mc_string_1 – mc_string_3stringMax 512 characters
mc_boolean_1 – mc_boolean_3boolean
mc_date_1string (ISO 8601)
ref_string_1 – ref_string_3stringMax 512 characters
ref_boolean_1 – ref_boolean_3boolean
ref_date_1string (ISO 8601)
md_string_1 – md_string_10stringMax 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.

Booleans, except date_optin.

FieldTypeNotes
email_optinboolean
sms_optinboolean
sms_opt_out_userboolean
comms_optin_e1 – comms_optin_e11boolean11 numbered slots
comms_optin_marketing_driveboolean
comms_optin_marketing_partnerboolean
date_optinstring (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

FieldType
name, surnamestring
company, department, designationstring
contact_id, contact_sourcestring
crm_subscription_id, request_idstring
family_uuidstring
custom_segment_idarray of number
pnstring (promotion name)
upnumber

Location

FieldType
city, country, country_code, region, location_statestring

Site and campaign context

FieldType
site_sectionstring (constrained when leads is declared — see 6.6)
site_type, device_typestring
campaign, medium, survey_urlstring
enquiry_type, enquiry_originstring
enquiry_postcodenumber
last_enquiry_datestring (ISO 8601 datetime)
has_transacted_carsforsaleboolean
has_transacted_financeboolean
has_transacted_newcarboolean
has_transacted_sellmycarboolean

Vehicle

FieldType
car_make, car_model, body_typestring
vehicle_make, vehicle_make_uuidstring
vehicle_model, vehicle_model_uuidstring
vehicle_variant, vehicle_variant_uuidstring
vehicle_badge, vehicle_body_type, vehicle_condition, vehicle_drive_codestring
vehicle_engine_description, vehicle_detailsstring
vehicle_key, vehicle_uuidstring
vehicle_main_image_urlstring
vehicle_registration_number, vin_numberstring
variant_long_description, variant_short_descriptionstring
vehicle_odometer_kms, vehicle_yearstring (string despite the name)
vehicle_price, vehicle_trade_max, vehicle_trade_minnumber

Dealer

FieldType
dealer_name, dealer_address, dealer_suburb, dealer_statestring
dealer_postcodenumber

6.6 The leads destination

Validated today, delivers nowhere yet. A real submission declaring leads returns 501; ?dryRun=1 validates it.

FieldTypeNotes
lead_first_namestringMandatory, non-empty
lead_last_namestringMandatory, non-empty
lead_emailstring (email)Mandatory. No fallback to the top-level email
lead_phonestringMandatory, 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_postcodestringMandatory. A real AU postcode: ^(?:(?:[2-8]\d|9[0-7]|0?[28]|0?9(?=09))(?:\d{2}))$
lead_typestringMandatory. One of new-car, finance, cars-for-sale, instant-offer, novated-lease
lead_originstringMandatory. One of website, livechat, unbounce, facebook, customer-service
site_sectionstringValue constrained when leads is declared. One of showrooms, reviews, cars-for-sale, whats-my-car-worth, other
lead_id, lead_source, lead_urlstringOptional
lead_state, lead_status, lead_suburbstringOptional
lead_enquiry_datetimestring (ISO 8601 datetime)Optional
lead_ip_addressstringOptional
lead_preferred_contact_methodstringOptional
lead_is_manually_allocated, lead_is_testbooleanOptional

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.

FieldTypeNotes
eml_commentsstringMandatory, non-empty. The enquiry body
eml_lead_first_namestringOptional

Sender, recipients and subject are server-side configuration, not caller input.

6.8 Strict typing

Nothing is coerced:

You sendResult
"ca_boolean_1": trueAccepted
"ca_boolean_1": "true"Rejected. A string is not a boolean
"ca_string_1": 42Rejected. 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": nullAccepted, then stripped before sending. Nothing is stored
A string slot over 512 charactersRejected

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:

  1. It must be a string. A number (61400000000) is rejected.
  2. It may contain only 0–9, space, +, (, ) and -. Anything else is a 400 — "???0400 000 123??" is rejected, not cleaned up.
  3. It must parse to a valid Australian number. Premium-rate, shared-cost and toll-free are rejected.
  4. 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:00 or 2026-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

#CheckFailure
1Content-Type, if sent, is application/json415 Unsupported content type
2Body parses as a JSON object400 Invalid request payload
3vendorId is a registered vendor400 Unknown vendorId
4destinations, if present, is a non-empty array of known ids400 Unknown destination
5Every declared destination is wired for delivery (real submissions only)501 Destination not yet available
6Turnstile token verifies, and its hostname is allow-listed (real submissions only)403 Turnstile verification failed
7Every declared destination's schema passes, date slots name a real day, and mobile, if present, parses as an AU number400 Validation failed
8Insider 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.

StatusMeaning
200Accepted and delivered, or a ?dryRun=1 preview
400Malformed JSON, unknown vendorId, bad destinations, or a schema failure
403Turnstile verification failed, or its hostname is not allow-listed
415Content-Type was sent and was not application/json
500Failure on our side. Always the same generic message
501A real submission named a destination that is not wired yet
otherAn upstream failure at Insider; its status is mirrored back. See 8.5

8.1 Success

json
{ "success": true, "status": 200, "message": "Success" }

8.2 Dry run

json
{
  "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

json
{
  "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

json
{ "success": false, "status": 403, "message": "Turnstile verification failed" }
json
{ "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:

json
{
  "success": false,
  "status": 502,
  "message": "Unable to submit the form",
  "requestId": "c3a1e6f2-6b31-4e2a-9f7a-1a2b3c4d5e6f"
}

8.6 Our side failed

json
{ "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 a 403, reset the widget for a fresh token first.
  • Retry with backoff: a flat 500 from 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 requestId where you can retrieve it later.
  • Never leave ?dryRun=1 on in production. It never delivers.

9. Testing with ?dryRun=1

POST /forms/submit?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 400 in the same shape.
  • Turnstile is skipped — no live token needed.
  • Nothing is delivered anywhere, including to Insider.
  • leads and email can be dry-run today even though they do not deliver.
  • Only the literal string 1 counts. ?dryRun=true, ?dryRun=0 and anything else are treated as a real submission. Worth an explicit test.

10. Worked examples

Success

bash
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"
  }'
json
{ "success": true, "status": 200, "message": "Success" }

Validation failure — a string in a boolean slot

bash
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"
  }'
json
{
  "success": false,
  "status": 400,
  "message": "Validation failed",
  "requestId": "c3a1e6f2-6b31-4e2a-9f7a-1a2b3c4d5e6f"
}

403 — a replayed token, or one issued on an unregistered hostname

bash
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"
  }'
json
{ "success": false, "status": 403, "message": "Turnstile verification failed" }

501 — a real submission naming leads

bash
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"
  }'
json
{ "success": false, "status": 501, "message": "Destination not yet available" }

Nothing was delivered, including to insider.

Dry run

bash
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"
  }'
json
{
  "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 400 never names the failing field. Use the requestId.
  • 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-DD is 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.

12. Getting help

  1. Run the payload through ?dryRun=1.
  2. Check Section 6 for the field, and Section 7 for the rule that rejected it.
  3. Contact the Drive platform team with the environment, your vendorId, the requestId if you got one, and otherwise the approximate time of the request.
Esc