Automatic sending
Use this route when your system can send a message to MarsX the moment a lead is created or changed. It is the fastest route, and the one MarsX prefers.
Each lead event is one signed request over the internet. MarsX stores it, answers 202, and applies it a moment later. Your system also hosts three small read-only pages. MarsX reads them to find anything a request missed.
New to this? Start with Testing and going live. It sends one test lead in about 20 minutes.
Using an AI coding agent?
Paste this prompt into your agent. It points the agent at this documentation and sets the rules for the whole job. You can also read it as plain text at /agent-prompt.txt.
The sandbox (api-sandbox.marsx.media) opens when MarsX sends your connection details. The live address (api.marsx.media) opens at go-live.
Read the prompt
You are helping us connect our system to the MarsX Lead Intake API, so that our customer enquiries reach Concour. Treat these two documents as the only source of truth, and do not guess beyond them: https://docs.marsx.media/llms-full.txt and https://docs.marsx.media/openapi.yaml. If we have not yet received sandbox details from MarsX, stop after reading the documents and planning the work. 1. First ask me which route we use: automatic sending (each lead as it happens), automatic file upload (one Excel file each working day), or upload by staff (a person uploads the file, so no code is needed). 2. When MarsX has sent our sandbox connection details, build and test against the sandbox first, https://api-sandbox.marsx.media, with invented data only. Keep the base URL, connection reference and keys in configuration, so going live is a configuration change, not new code. 3. Sign every request exactly as the "Sign the request" section of the Automatic sending page describes, and prove your code against its worked examples before sending anything. 4. Only a 202 answer means MarsX accepted an event. Only a 201 answer means MarsX stored a file. Retry exactly as documented, with the same webhook-id. 5. Send withdrawals and deletions as soon as the customer asks. With automatic sending, send each as its own event straight away. With a file route, send a file within 24 hours of the request. 6. For automatic sending, also build the three read-only endpoints that MarsX calls to catch missed leads. 7. Keys come from MarsX through a one-time secure link. Ask me for them. Never hard-code them, never log them, and never log customer data. 8. Run every sandbox test case on the Testing and going live page, show me the results, and send them to MarsX as that page describes. 9. Go live only after MarsX confirms in writing that the sandbox tests passed and sends the production connection reference and keys through a new one-time secure link. Then switch the configuration to https://api.marsx.media, send the first real lead, and confirm with me and MarsX that it arrived. With automatic sending, use the status check. With a file route, MarsX emails the outcome. Sandbox keys never work in production, and production keys never go into the sandbox. Ask me before making any assumption that the documentation does not settle.
Where to send
Section titled “Where to send”| Environment | Address | Use |
|---|---|---|
| Production | https://api.marsx.media |
Live leads |
| Sandbox | https://api-sandbox.marsx.media |
Testing with made-up data only. Events sent here never reach dealers |
Every request goes to a connection: the link between your company and MarsX, for one brand. MarsX gives each connection its own reference, for example conn_exampleexample22. That reference is made up. MarsX issues a different reference, with its own keys, for the sandbox and for production. You do not choose it.
Send a request
Section titled “Send a request”| Item | Value |
|---|---|
| Address | POST https://api.marsx.media/v1/sources/{connection}/events |
| Sandbox | https://api-sandbox.marsx.media. Made-up data only. Events sent here never reach dealers |
| One event for each request | Yes. No batches |
| Content type | application/json. A parameter such as charset=utf-8 is accepted and ignored. Any other type gets 415 |
| Body | UTF-8, no byte-order mark |
| Transport | HTTPS only, TLS 1.2 or higher. MarsX never redirects, and a redirect counts as a failure |
| Timeout | Set 15 to 30 seconds on your side, as Standard Webhooks recommends |
| Size | A body of up to 32,768 bytes. A lead with every field filled and a 500-character note is about 4 KB |
Sign on your server only. Never call this address from a browser or an app.
Sign on your server only. Never call this address from a browser or an app.
The four events
Section titled “The four events”type |
data.record_action |
When to send |
|---|---|---|
lead.new |
new |
A first enquiry |
lead.update |
update |
Any later change to the lead. Send the full current state, a higher source_revision and the time of the change in updated_at |
lead.withdraw |
withdraw |
The customer withdraws consent or asks to restrict processing |
lead.delete |
delete |
The customer asks for erasure |
Send a withdrawal or deletion at once. Never batch it. Handle withdrawal and deletion has the detail. The fields are in Lead fields. The signing steps are in Signing and worked examples.
The fields of each event are in Lead fields. A lead.new needs only eight fields: partner_lead_id, record_action, created_at, customer_name, a phone or an email, source_channel, contact_consent (yes) and consent_time. What blocks a lead lists the rest.
Sign the request
Section titled “Sign the request”MarsX follows the open standard Standard Webhooks 1.0.0: standardwebhooks.com. Libraries for many languages are on that site. Use one if you can.
A signature is a short piece of text that only the holder of a secret can produce. MarsX checks it to be sure that a request came from you and arrived unchanged.
Headers
Section titled “Headers”| Header | Value |
|---|---|
webhook-id |
A unique ID for the event. It matches ^[A-Za-z0-9_-]{1,128}$, so your own event UUID works. The same value on every retry of the same event. No full stops |
webhook-timestamp |
The time of this attempt, in whole seconds since the Unix epoch (1 January 1970, UTC). It changes on every retry |
webhook-signature |
One or more signatures, separated by a space. Each is v1,<base64> |
What is signed
Section titled “What is signed”Join three parts with full stops:
<webhook-id>.<webhook-timestamp>.<raw request body>The body is the exact bytes you send. Do not parse and re-serialise it between signing and sending. A single extra space breaks the signature.
The signing scheme
Section titled “The signing scheme”Every request is signed with HMAC v1. This is the only scheme to use.
| Item | v1 |
|---|---|
| Algorithm | HMAC-SHA256 |
| Key | One shared secret. Both sides hold it |
| Key display | whsec_ followed by the base64 of 24 to 64 random bytes |
| Header value | v1, followed by the base64 of the 32-byte digest |
MarsX compares signatures in constant time.
Clock window
Section titled “Clock window”MarsX rejects a request when webhook-timestamp is more than 300 seconds away from the MarsX clock, in either direction. Keep your servers on network time (NTP). On every retry, use a fresh webhook-timestamp and a fresh signature. The webhook-id stays the same.
Why requests are signed
Section titled “Why requests are signed”- The signature proves that the request came from you. If one character changes on the way, the signature no longer fits.
- The timestamp stops someone from copying a real request and sending it again later. A copied request goes stale in five minutes.
- The
webhook-idlets MarsX recognise a request that arrives twice, for example after a lost answer. It stores nothing new. It never decides which version of a lead wins. Thesource_revisiondecides that. - One key for each connection and environment keeps the damage small if a key leaks. The sandbox key never works in production, and a key from one connection never works on another.
- Signing happens on your server only. Anything in a browser or an app can be read by whoever uses it.
Worked examples
Section titled “Worked examples”Use these made-up inputs to check your code before you send anything. They are called worked examples because every input and the expected result are given.
| Input | Value |
|---|---|
| Secret | whsec_ZmljdGlvbmFsLXRlc3Qtc2VjcmV0LWRvLW5vdC11c2U= |
Secret after you remove whsec_ and decode the base64 |
The 32 ASCII characters fictional-test-secret-do-not-use |
webhook-id |
msg_test_0001 |
webhook-timestamp |
1791100800 (4 October 2026, 15:00:00 at +07:00) |
| Body | The single line below, 350 bytes, no line break at the end |
Body:
{"type":"lead.withdraw","timestamp":"2026-10-04T15:00:00+07:00","data":{"partner_lead_id":"SRC-TEST-0001","source_revision":3,"record_action":"withdraw","created_at":"2026-10-01T09:15:00+07:00","updated_at":"2026-10-04T15:00:00+07:00","request_type":"withdraw_consent","request_time":"2026-10-04T14:59:30+07:00","request_reference":"PRIV-TEST-0001"}}The string that is signed (the ID, a full stop, the timestamp, a full stop, then the body):
msg_test_0001.1791100800.{"type":"lead.withdraw","timestamp":"2026-10-04T15:00:00+07:00","data":{"partner_lead_id":"SRC-TEST-0001","source_revision":3,"record_action":"withdraw","created_at":"2026-10-01T09:15:00+07:00","updated_at":"2026-10-04T15:00:00+07:00","request_type":"withdraw_consent","request_time":"2026-10-04T14:59:30+07:00","request_reference":"PRIV-TEST-0001"}}Expected result:
webhook-signature: v1,fzi3+AruxTHPp63y+tTDa97ZUOcbMHHhKNLnXyz/CSM=Any HMAC-SHA256 implementation, given the decoded key and the signed string above, must produce the same 32 bytes, written in base64. These functions produce it:
import base64, hashlib, hmac
def sign_v1(secret: str, webhook_id: str, timestamp: str, raw_body: bytes) -> str: key = base64.b64decode(secret.removeprefix("whsec_")) signed = webhook_id.encode() + b"." + timestamp.encode() + b"." + raw_body digest = hmac.new(key, signed, hashlib.sha256).digest() return "v1," + base64.b64encode(digest).decode()import { createHmac } from "node:crypto";
export function signV1(secret, webhookId, timestamp, rawBody) { const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const digest = createHmac("sha256", key) .update(`${webhookId}.${timestamp}.`) .update(rawBody) .digest("base64"); return `v1,${digest}`;}The timestamp in this vector is far from today. A live MarsX address would reject it. Use the vector for offline checks of your signing code only.
Two signatures in one header
Section titled “Two signatures in one header”During a key change, send both signatures, separated by a space. MarsX accepts either.
webhook-signature: v1,<signature with the old key> v1,<signature with the new key>Common mistakes
Section titled “Common mistakes”- Signing a re-serialised body instead of the bytes that go on the wire.
- Reusing an old
webhook-timestampon a retry. - Putting a full stop inside
webhook-id. - Using the sandbox key in production.
- Leaving off the
v1,prefix in the header.
Read the answers and retry
Section titled “Read the answers and retry”Only 202 confirms acceptance. 202 means MarsX has the event safely stored. It does not mean the lead has changed yet.
| Status | What it means | What you do |
|---|---|---|
202 |
Stored and queued. The body says accepted or duplicate. |
Done. Do not resend. |
400 |
The body is not valid JSON, the event type is unknown, record_action does not match type, or a field is missing or not valid. |
Fix it. Do not retry the same request. |
401 |
The signature is missing or wrong, the timestamp is outside the window, or the URL or environment is wrong. All give the same answer. | Check the key, the clock, the URL and the environment. Do not retry the same request. |
413 |
The body is over 32,768 bytes, or over the transport cap of the route. | Fix it. Do not retry. |
415 |
The content type is not application/json. |
Fix it. Do not retry. |
429 |
Too many requests. The Retry-After header says when to try again. |
Wait at least Retry-After, then retry the same event. |
502, 503, 504, other 5xx, a timeout or a dropped connection |
A temporary fault. The event may already be stored. | Slow down. Wait at least Retry-After if present. Retry the same event. |
“Do not retry” means no automatic retry loop. The event is still owed. After you fix the cause, send it again with the same webhook-id, a fresh timestamp and a fresh signature. A rejected withdrawal or deletion matters most. Do not drop it.
Retry safely
Section titled “Retry safely”A gateway can fail after MarsX saves an event, so a timeout does not mean the event was lost. MarsX removes repeats by webhook-id, so a retry is always safe: a repeat gets 202 with result duplicate and is not processed twice.
On every retry:
- keep the same
webhook-idand the same body - use a fresh
webhook-timestampand a fresh signature - wait at least
Retry-Afterwhen the answer carries it - add random jitter to every delay, so many failures do not retry at the same moment
Use the example schedule from Standard Webhooks (exponential backoff over several days). Retry for at least 3 days, which is 10 attempts:
| Attempt | Delay before it | Time since the first try |
|---|---|---|
| 1 | Immediately | 00:00:00 |
| 2 | 5 seconds | 00:00:05 |
| 3 | 5 minutes | 00:05:05 |
| 4 | 30 minutes | 00:35:05 |
| 5 | 2 hours | 02:35:05 |
| 6 | 5 hours | 07:35:05 |
| 7 | 10 hours | 17:35:05 |
| 8 | 14 hours | 31:35:05 |
| 9 | 20 hours | 51:35:05 |
| 10 | 24 hours | 75:35:05 |
Do not delay a withdrawal or deletion because an earlier event for another lead is still retrying.
After the last attempt, keep the event visible to your operators so they can re-send it. Also email the MarsX technical contact (support@marsx.media) with the connection, the webhook-id and the event type. The email carries no customer data. The catch-up checks below still find the lead later.
Stay within the rate budget
Section titled “Stay within the rate budget”MarsX stores each event and answers within 10 seconds. It then processes the event from a queue, so normal bursts never need throttling. Keep each connection at or below 100 event requests per 10 seconds.
MarsX may answer any request with 429, 502, 503 or 504. Treat each as a signal to slow down. Wait at least the Retry-After value when present, and otherwise continue your normal retry schedule. None of these answers confirms acceptance, and neither does a timeout or a dropped connection.
Send a withdrawal or deletion
Section titled “Send a withdrawal or deletion”When a customer asks you to stop using their data, tell MarsX at once.
Choose the right event
Section titled “Choose the right event”| The customer | request_type |
Event (automatic sending) | record_action (file) |
|---|---|---|---|
| Withdraws consent | withdraw_consent |
lead.withdraw |
withdraw |
| Asks to restrict processing | restriction |
lead.withdraw |
withdraw |
| Asks for erasure | erasure |
lead.delete |
delete |
MarsX stores request_type unchanged in its privacy request register. In release 1, a restriction and a withdrawal set the same marker.
Send it as a removal event
Section titled “Send it as a removal event”A removal event carries identity and request fields only. It must not carry customer contact, interest or profile fields.
{ "type": "lead.withdraw", "timestamp": "2026-10-04T15:00:00+07:00", "data": { "partner_lead_id": "SRC-TEST-0001", "source_revision": 3, "record_action": "withdraw", "created_at": "2026-10-01T09:15:00+07:00", "updated_at": "2026-10-04T15:00:00+07:00", "request_type": "withdraw_consent", "request_time": "2026-10-04T14:59:30+07:00", "request_reference": "PRIV-TEST-0001" }}request_reference is your own reference for the request. It must match ^[A-Za-z0-9._-]{1,64}$. Together with the connection and the lead ID, it lets MarsX recognise a repeat of the same request.
Send it quickly
Section titled “Send it quickly”Send the event as soon as the customer asks. Never batch it. Never hold it behind another event that is still retrying. Keep a record of each request and its request_reference.
What MarsX does when it receives it
Section titled “What MarsX does when it receives it”- MarsX restricts the lead on receipt. When MarsX stores a
lead.withdraworlead.delete, it places a pending restriction on the lead before the background worker runs. No salesperson can see or contact the lead in between. This holds even when MarsX has not seen the lead before. - MarsX applies privacy events first, before other events in its queue.
- The revision number does not block it. MarsX applies a withdrawal or deletion even when its
source_revisionis lower than the one held. - A deletion after a withdrawal is allowed. Use a new
request_reference. An erasure after a withdrawal is a new request, not a conflict. - The marker stays. After a withdrawal or deletion, the privacy marker stays until an approved privacy process lifts it. A later
neworupdatefor that lead is set aside for the privacy operator. A source file or event cannot lift a marker. - After an erasure, MarsX keeps minimal suppression data only: the lead ID, the marker and the request details.
If MarsX rejects the event
Section titled “If MarsX rejects the event”A 400, 413 or 415 stores no lead data. MarsX keeps a restricted rejection record with the reason, the instance reference, the connection, the event type and the lead ID if it could be read. The record holds no customer data. A refused withdrawal or deletion also alerts the MarsX privacy operator.
You still have to act.
- Read the
detailand theerrorsin the answer. See Errors. - Fix the cause.
- Send the event again with the same
webhook-id, a fresh timestamp and a fresh signature. - Check the outcome with the status call, if you use it.
Do not drop a rejected privacy event.
What MarsX does with an event
Section titled “What MarsX does with an event”An event goes through two stages, in this order.
- At the door. This is the answer to your
POST. MarsX checks the signature, the timestamp and the shape of the data. It stores the event in an inbox and answers202. The answer isaccepted, orduplicatewhen MarsX has already received thiswebhook-idon this connection.webhook-idremoves repeated deliveries and does nothing else. - After storage. A background worker applies the event under the rules below. The
POSTanswer does not report the outcome. You can read it through the status call.
The answer to the POST tells you that MarsX holds the event safely. It does not tell you what the worker decided.
If the answer is lost or is an error
Section titled “If the answer is lost or is an error”| What you see | What it means | What to do |
|---|---|---|
202 is lost on the way back |
The event is stored. You do not know it | Retry with the same webhook-id. MarsX answers 202 with result duplicate and stores nothing new |
429, 502, 503, 504 or a timeout |
Not a confirmation. The event may already be stored | Wait, at least Retry-After, with backoff and jitter. Retry with the same webhook-id and body, a fresh timestamp and a fresh signature |
400, 401, 413 or 415 |
No lead data was stored. MarsX recorded the rejection | Fix the cause, then send the event again |
What the worker decides
Section titled “What the worker decides”The worker uses source_revision to decide the order of events. Arrival order, webhook-id and webhook-timestamp do not matter. The worker may process events in a different order from the order they arrived.
| Situation | What the worker does | Processing state |
|---|---|---|
new or update with a higher source_revision than the stored one |
Replaces all fields you own with the full state in the event | applied |
update or new for a lead MarsX does not know |
Applies it as the first state of that lead | applied |
new or update with the same revision and identical content |
Nothing. A safe re-send under a new webhook-id |
ignored_duplicate_content |
new or update with the same revision and different content |
Applies neither version. Sets the event aside and alerts MarsX | quarantined, reason revision_conflict |
new or update with a lower revision |
Logs it and ignores it | ignored_stale_revision |
withdraw or delete, at any revision |
Applies it even when the revision is lower than the stored one. Turns the pending restriction into a privacy marker and restricts the personal data | applied |
delete after an earlier withdraw of the same lead, with a new request_reference |
Applies it. An erasure after a withdrawal is a new request, not a conflict | applied |
withdraw or delete that repeats an earlier privacy request (same request_reference) with identical content |
Nothing | ignored_duplicate_content |
withdraw or delete that repeats an earlier privacy request with different content |
Sets it aside for the privacy operator | quarantined, reason privacy_request_conflict |
new or update after a privacy marker exists |
Sets it aside for the privacy operator, whatever the revision. Personal data cannot be restored this way | quarantined, reason privacy_marker_set |
The path of an event, in words
Section titled “The path of an event, in words”- An event is stored. Its state is
queued. Awithdrawordeletesets a pending restriction at this moment. - The worker takes it. It becomes
applied(a higher revision, or a withdraw or delete at any revision),ignored_stale_revision(a lower revision),ignored_duplicate_content(same revision, same content) orquarantined(a conflict, or an update after a privacy marker). - Each of these end states is final.
Rules to remember
Section titled “Rules to remember”source_revisionmust go up with every change to a lead. It must never be reused for different content. On alead.newyou may leave it out, and MarsX uses 1. For a change, always send a higher revision number and the time of the change.- MarsX applies privacy events before other events in its queue.
- A new event for a lead that MarsX already knows is never ignored just because the lead is known. The rules above judge it.
- Withdrawals and deletions restrict the lead on receipt, before the worker runs. No salesperson can see or contact the lead in between.
Check the outcome
Section titled “Check the outcome”The answer to a POST shows intake only. To see what the worker decided, make one read-only call. It is optional. Leads flow without it.
GET https://api.marsx.media/v1/sources/{connection}/events/{webhook_id}{ "webhook_id": "msg_test_0001", "type": "lead.withdraw", "partner_lead_id": "SRC-TEST-0001", "source_revision": 3, "processing_state": "applied", "received_at": "2026-10-04T15:00:01+07:00", "processed_at": "2026-10-04T15:00:03+07:00"}processing_state |
Meaning |
|---|---|
queued |
Stored and waiting for the worker. processed_at is null. Ask again later. |
applied |
The worker applied the event. |
ignored_stale_revision |
A new or update with a lower revision than the one held. Logged and ignored. |
ignored_duplicate_content |
The same revision with the same content was already applied, or an identical privacy request was repeated. Nothing changed. |
quarantined |
Stored but not applied. A reason says why. MarsX reviews it. |
Rules for this call:
- It is read-only. It returns the state of one event and nothing else. It carries no customer data.
- It uses OAuth 2.0 client credentials, issued by MarsX. The token address is
https://api.marsx.media/oauth/token(sandbox:https://api-sandbox.marsx.media/oauth/token). The scope isevents:read. Client authentication is HTTP Basic. Tokens last 3600 seconds. - You get one client for each connection and environment. A token from another connection fails.
404means MarsX has no event with thatwebhook-idfor your connection. Either MarsX never stored it, so send it again, or the status is older than 90 days. MarsX keeps status andwebhook-idde-duplication for 90 days.- Keep status requests at or below 60 per 60 seconds for each connection. Read the status when you need it. Do not poll every event in a tight loop.
The reason values are revision_conflict, privacy_marker_set and privacy_request_conflict. A quarantined event needs no resend. For a revision_conflict, find out why one revision number carried two different contents.
Warnings. The response can carry an optional warnings array. It is present only when MarsX applied the event and something in it was not used. The lead was accepted. Each item has a code and a reason:
"warnings": [{ "code": "dealer_code_ignored", "reason": "unknown" }]The code dealer_code_ignored means MarsX ignored the preferred_dealer_code and routed the lead as if no code had been sent. The reason is unknown (the code is not on the MarsX dealer list) or not_active (the code names an outlet that takes no car sales lead). Nothing needs resending. Treat an unknown code or reason as informational.
Host three read-only pages
Section titled “Host three read-only pages”A send can fail without anyone noticing. So MarsX checks your side on a schedule, and only reads. Nothing is written to your system. This is the safety net. It is not a second way to deliver leads.
| Endpoint | Purpose | MarsX reads it |
|---|---|---|
GET /v1/changes?cursor=&limit= |
Lead revisions that changed | Every 15 minutes |
GET /v1/privacy-events?cursor=&limit= |
Every withdrawal, deletion and restriction | Every 15 minutes |
GET /v1/leads/{partner_lead_id} |
The full current state of one lead | When MarsX finds a lead it lacks or holds at an older revision |
You may choose your own path prefix and host name. The names, query parameters and response shapes stay as in the API reference.
| Requirement | Detail |
|---|---|
| Replay window | Both feeds reach back at least 90 days |
| Page size | Default at least 100. Maximum at least 500. MarsX asks for limit=500 and accepts fewer |
| Request budget | At least 60 requests per minute for each client on each endpoint. MarsX stays at or below 1 request per second and honours Retry-After |
| Transport | HTTPS, TLS 1.2 or higher, no redirects. The sandbox host differs from the production host |
| Authentication | OAuth 2.0 client credentials, a read-only scope, tokens of 300 to 86,400 seconds, HTTP Basic client authentication |
MarsX has no fixed addresses, so an IP allow-list does not work. The identity of a MarsX call is its OAuth client. Each call also carries User-Agent: MarsX-Reconciler/1, Accept: application/json and Authorization: Bearer <access_token>.
What the three endpoints return
GET /v1/changes lists the leads that changed:
{ "items": [ { "partner_lead_id": "SRC-TEST-0001", "source_revision": 2, "updated_at": "2026-10-04T10:20:00+07:00" }, { "partner_lead_id": "SRC-TEST-0002", "source_revision": 1, "updated_at": "2026-10-04T10:20:00+07:00" } ], "next_cursor": "opaque-cursor-example-0002", "has_more": true}GET /v1/privacy-events lists every withdrawal, deletion and restriction. Include leads you no longer hold. Items carry no customer contact, interest or profile data.
{ "items": [ { "partner_lead_id": "SRC-TEST-0001", "request_type": "withdraw_consent", "request_time": "2026-10-04T14:59:30+07:00", "request_reference": "PRIV-TEST-0001" }, { "partner_lead_id": "SRC-TEST-0007", "request_type": "erasure", "request_time": "2026-10-04T15:30:00+07:00", "request_reference": "PRIV-TEST-0002" } ], "next_cursor": "opaque-cursor-example-0102", "has_more": false}GET /v1/leads/{partner_lead_id} returns what you would send as the latest event for that lead, in the same data shape as the automatic sending events.
| Lead state at your end | Answer |
|---|---|
| Live | 200 with the full current state. record_action is new or update |
| Withdrawn or restricted | 200 with the withdrawal form: identity and privacy request fields only |
| Erased and no longer held | 404. MarsX finds the erasure in privacy-events. This 404 applies only to an authenticated caller |
Cursor rules
A cursor is a marker that tells an API where the last page stopped.
next_cursoris opaque. MarsX stores it and sends it back unchanged. Return it on every page, including an empty page.- Pages come oldest first. The order must stay stable when several changes share the same
updated_at. One way: order byupdated_at, then by a unique, increasing sequence number that your database assigns when the change is committed. - A change must never appear behind a cursor that you have already handed out.
- Leave out
cursorto start at the oldest item you hold. - A cursor older than the replay window gets
410.
An expired cursor fails closed
- If the
privacy-eventscursor has expired, MarsX cannot prove that it has seen every withdrawal and deletion. MarsX keeps the leads of that connection restricted for contact until recovery is complete and proven. - Starting again from the oldest item you still hold does not prove completeness. A deletion older than the replay window would be missed.
- So you must be able to supply, when MarsX asks, a full list of the lead IDs of the connection that are currently withdrawn, restricted or erased, including IDs you no longer hold. Send it as a template file with only
withdrawanddeleterows, named<partner_code>_<YYYY-MM-DD>_privacy-list.xlsx, through the file upload. MarsX lifts the restriction only when recovery is proven. - For
changes, an expired cursor means MarsX starts again from the oldest item held and re-reads the leads it holds. It shows the connection as not verified for completeness until you have resent any lead that fell in the gap. A resend is a normal push.
Your own API token service
Section titled “Your own API token service”MarsX authenticates to your endpoints with OAuth 2.0 client credentials (RFC 6749 section 4.4). The token request looks like this:
POST <your token URL>Content-Type: application/x-www-form-urlencodedAuthorization: Basic <base64(client_id:client_secret)>
grant_type=client_credentials&scope=leads:readThe answer is {"access_token":"...","token_type":"Bearer","expires_in":3600}. Every read call carries Authorization: Bearer <access_token>. MarsX asks for a new token after a 401 or when expires_in has passed.
You issue one client for each connection and environment to MarsX, through the secure key exchange. The scope allows reading only, and you name it. The token address uses HTTPS. MarsX accepts a static key with an IP allow-list only as a recorded exception.
Publish your contract and a sandbox
Section titled “Publish your contract and a sandbox”Publish one OpenAPI 3.1 document with a webhooks section for the events and paths for the three endpoints above. The contract file is a ready base. Keep webhooks and the three paths. Remove the two paths that MarsX hosts: the event status call and the file upload.
Also provide a sandbox with made-up data only. The sandbox must document one cursor value that always returns 410, so MarsX can run the expired-cursor test.
Change your key
Section titled “Change your key”A signing key should change from time to time, and at once if you think someone else has seen it. You can change a key without dropping events. It applies to automatic sending and to the automatic file upload.
During a short overlap, you sign every request with both the old and the new key. You send both signatures in the same header, separated by a space. MarsX accepts either one. When the overlap ends, MarsX retires the old key.
- You have one key for each connection and each environment. The sandbox key never works in production. A key from one connection never works on another.
- Keys are exchanged through the one-time secure key exchange. They never travel in an email or a WhatsApp message.
- The overlap lasts at most 7 days. MarsX retires the old key 7 days after it activates the new one, or earlier when you confirm in writing.
- On suspected exposure, MarsX retires the old key at once. Tell the technical contact immediately.
-
Ask for a new key. Write to your technical contact. Say which connection and which environment.
-
Receive the new key through the secure key exchange. MarsX activates it for your connection.
-
Sign with both keys. For every request, make one signature with the old key and one with the new key.
-
Send both in the header, separated by a space:
webhook-signature: v1,<signature with the old key> v1,<signature with the new key> -
Check that events are accepted with the old key alone, with the new key alone, and with both. Test this in the sandbox first.
-
Switch to the new key alone once the overlap ends or when MarsX confirms the old key is retired. You may confirm in writing that you are ready, which lets MarsX retire the old key earlier.
Each signature is v1,<base64>. The scheme is explained in Signing and worked examples.
Test it
Section titled “Test it”Test P12 in the sandbox checks rotation: it sends a request with the old key, with the new key and with both, and expects 202 accepted each time, with no event lost. See Test in the sandbox.
Privacy and security rules
Section titled “Privacy and security rules”| Rule | Detail |
|---|---|
| Transport | HTTPS only, TLS 1.2 or higher. No redirects |
| Key exchange | The issuer of a key or client credential shares it through a one-time, expiring secure share. Credentials never travel in an email or a WhatsApp message. Sandbox and production use separate shares |
| Lead files | Never by email or WhatsApp. Use the signed upload or the upload by staff account |
| Replay protection | A signed timestamp with a 300 second window, and webhook-id de-duplication |
| Privacy events | Sent as soon as the customer asks. Never batched. Replayable for at least 90 days. On a file route, the 24 hour file rule applies |
| After a withdrawal or deletion | The privacy marker stays until an approved privacy process lifts it. A later new or update for that lead is set aside for the privacy operator. A source file or event cannot lift a marker |
| Removal events | Carry identity and request fields only. No contact, interest or profile data |
| After an erasure | MarsX keeps minimal suppression data only: the lead ID, the marker and the request details |
| Consent evidence | Every new and update carries contact_consent (yes) and consent_time. consent_reference, notice_version and consent_purpose are optional. A consent reference points to the real record, not to a yes or no value |
| Logging by you | Do not write secrets or full request bodies to logs. Do not put personal data in URLs or in error messages |
| Logging by MarsX | Metadata only: connection, event type, webhook-id, instance, status and timing. Never field values, request bodies, signatures or tokens |
| Browsers | Never call the MarsX endpoint from a browser or an app. Sign on your server only |
| Write-back | None. MarsX never writes anything back to your system |
| Errors | application/problem+json. They never echo personal data |
Why dealers work only in Concour
Section titled “Why dealers work only in Concour”Concour is the only working surface for the brand and its dealers. Your data is a feed. If the same leads also appear in your own dashboard, dealers could work two statuses and do the work twice. So:
- Leads that you also show in your own dashboard must be read-only there, or carry no status field. This is a condition of the connection.
- You never send status, assignment, follow-up, test drives, sales or SPK numbers to MarsX.
- Any status in your own dashboard is not authoritative. It does not feed MarsX state, workflow or reports.
MarsX owns assignment, workflow status, follow-up, appointments and outcomes. An event from you never overwrites them. MarsX never sends any field back to you.
Why AI values are kept apart
Section titled “Why AI values are kept apart”ai_intent_label and ai_summary are optional. MarsX stores them apart from customer facts, and they never decide access or routing. Do not send AI guesses about affordability or location.
Never do this
Section titled “Never do this”Never call MarsX from a browser or an app
Your signing key must stay on your server. Anything in a browser can be read by its user.
Never reuse an old timestamp or signature on a retry
Keep the same webhook-id. Make a fresh timestamp and a fresh signature every time.
Never treat anything but 202 as confirmation
A timeout, 429, 502, 503 or 504 does not confirm anything. Retry the same event.
Never log secrets, bodies or personal data
Keep keys and full request bodies out of logs. Keep personal data out of URLs and error messages.
Never reuse a revision number for different content
The number must rise with every change. Reuse sets the event aside as a conflict.
Never send status or sales outcomes
No status, assignment, follow-up, test drives, sales or SPK numbers. Dealers record these in Concour.
Never drop a rejected withdrawal or deletion
Fix the cause and send it again. Do not batch these events or hold them for later.
Never send identity or money documents
No KTP or family-card numbers, income, financing documents or full chat transcripts. No AI guesses about affordability or location.
Go live
Section titled “Go live”Run the checks in Testing and going live. That page also lists the go-live steps.
