Testing and going live
The sandbox is a test copy of the service. It holds made-up data only. Nothing sent to it reaches a dealer. Build and test here first. Go live only when the steps at the end of this page are done.
Keep the base address, the connection reference and the keys in configuration. Then going live is a change of configuration and not new code. A sandbox key never works in production, and a production key never goes into the sandbox.
Send your first test lead
Section titled “Send your first test lead”This part is for engineers on automatic sending. It sends one made-up lead to the sandbox and takes about 20 minutes. You finish with a real 202 answer from MarsX.
You need three things:
- Node.js 18 or later, or Python 3.9 or later. The examples need no extra packages.
- A sandbox connection reference. It looks like
conn_followed by 16 letters and digits. The examples useconn_exampleexample22, which is made up. - A sandbox signing secret. It starts with
whsec_.
MarsX sends the reference and the secret to your technical contact through a one-time secure link. If you do not have them, write to fjrgumilang@gmail.com and ask for a sandbox connection.
Step 1: write the lead
Section titled “Step 1: write the lead”Save this as lead.json in an empty folder. Every value is made up.
{ "type": "lead.new", "timestamp": "2026-10-01T09:15:00+07:00", "data": { "partner_lead_id": "SRC-TEST-0001", "partner_customer_id": "SRC-CUST-TEST-0001", "source_revision": 1, "record_action": "new", "created_at": "2026-10-01T09:15:00+07:00", "updated_at": "2026-10-01T09:15:00+07:00", "customer_name": "Contoh Pelanggan", "phone": "+6280000000001", "email": "contoh.pelanggan@contoh.example", "model_interest": "Model A", "purchase_timeframe": "3 bulan ke depan", "test_drive_requested": "N", "enquiry_notes": "Ingin meminta penawaran untuk Model A.", "customer_province": "Jawa Barat", "customer_city": "Kota Bandung", "source_channel": "chat", "campaign": "campaign-test", "utm_source": "example", "utm_medium": "chat", "utm_campaign": "campaign-test", "session_id": "SESSION-TEST-0001", "consent_reference": "CONSENT-TEST-0001", "contact_consent": "yes", "consent_time": "2026-10-01T09:14:40+07:00", "notice_version": "NOTICE-TEST-1", "consent_purpose": "sales_follow_up", "ai_intent_label": "ingin_penawaran", "ai_summary": "Pelanggan meminta penawaran Model A." }}The message has three parts: a type that says what happened, a timestamp for when it happened, and data with the lead. Lead fields explains every field.
Step 2: sign it and send it
Section titled “Step 2: sign it and send it”The script below signs the lead and sends it. Sign the request explains each part and gives worked examples. You can check your signing code against them without the network.
import { createHmac, randomUUID } from "node:crypto";import { readFileSync } from "node:fs";
const host = "https://api-sandbox.marsx.media";const connection = process.env.MARSX_CONNECTION; // for example conn_exampleexample22const secret = process.env.MARSX_SIGNING_SECRET; // starts with whsec_
if (!connection || !secret) { console.error("Set MARSX_CONNECTION and MARSX_SIGNING_SECRET first."); process.exit(1);}
// The exact bytes you send are the exact bytes you sign.const body = readFileSync("lead.json");
const id = process.env.MARSX_WEBHOOK_ID ?? `msg_${randomUUID()}`;const timestamp = String(Math.floor(Date.now() / 1000));
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");const signature = "v1," + createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest("base64");
const response = await fetch(`${host}/v1/sources/${connection}/events`, { method: "POST", headers: { "content-type": "application/json", "webhook-id": id, "webhook-timestamp": timestamp, "webhook-signature": signature, }, body,});
console.log(response.status, await response.text());import base64, hashlib, hmac, os, sys, time, uuidimport urllib.error, urllib.request
host = "https://api-sandbox.marsx.media"connection = os.environ.get("MARSX_CONNECTION") # for example conn_exampleexample22secret = os.environ.get("MARSX_SIGNING_SECRET") # starts with whsec_
if not connection or not secret: sys.exit("Set MARSX_CONNECTION and MARSX_SIGNING_SECRET first.")
# The exact bytes you send are the exact bytes you sign.with open("lead.json", "rb") as f: body = f.read()
webhook_id = os.environ.get("MARSX_WEBHOOK_ID") or f"msg_{uuid.uuid4()}"timestamp = str(int(time.time()))
key = base64.b64decode(secret.removeprefix("whsec_"))signed = webhook_id.encode() + b"." + timestamp.encode() + b"." + bodysignature = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
request = urllib.request.Request( f"{host}/v1/sources/{connection}/events", data=body, method="POST", headers={ "content-type": "application/json", "webhook-id": webhook_id, "webhook-timestamp": timestamp, "webhook-signature": signature, },)try: with urllib.request.urlopen(request, timeout=30) as response: print(response.status, response.read().decode())except urllib.error.HTTPError as error: print(error.code, error.read().decode())Set the two variables and run the script.
export MARSX_CONNECTION="conn_exampleexample22" # use your own sandbox referenceexport MARSX_SIGNING_SECRET="whsec_..." # use your own sandbox secretnode send-lead.mjsexport MARSX_CONNECTION="conn_exampleexample22" # use your own sandbox referenceexport MARSX_SIGNING_SECRET="whsec_..." # use your own sandbox secretpython3 send_lead.pyStep 3: read the answer
Section titled “Step 3: read the answer”You should see this:
202 {"result":"accepted","webhook_id":"msg_...","request_id":"req_..."}202 means MarsX has your event safely stored. It does not mean the lead has changed yet. Only 202 confirms acceptance.
| Field | Meaning |
| result | accepted means a new event was stored and queued. duplicate means MarsX had already received this webhook-id. |
| webhook_id | The ID you sent. |
| request_id | A MarsX reference for support. Quote it if you write to MarsX. |
Step 4: break it on purpose
Section titled “Step 4: break it on purpose”Mistakes are normal. It helps to see them once, in a place where they cost nothing.
A wrong secret. Change one character of MARSX_SIGNING_SECRET and run the script again. You get 401:
{ "type": "urn:marsx:problem:unauthorized", "title": "Authentication failed", "status": 401, "detail": "The request could not be authenticated. Check the key, the clock, the URL and the environment.", "instance": "req_example_0006"}The message is the same for a wrong key, a wrong clock, a wrong URL and an unknown connection. MarsX does this on purpose, so a stranger cannot learn which connections exist. Errors lists every answer.
A repeated event. Run the script twice with the same ID:
MARSX_WEBHOOK_ID=msg_tutorial_0001 node send-lead.mjsMARSX_WEBHOOK_ID=msg_tutorial_0001 node send-lead.mjsThe first answer says "result":"accepted". The second says "result":"duplicate". Nothing new was stored. This is what makes a retry safe. If you ever lose an answer, send the same event again with the same webhook-id.
How the tests run
Section titled “How the tests run”- All tests use made-up data. You run your sender against the MarsX sandbox. MarsX runs its catch-up checks against your sandbox, if you use automatic sending.
- Sandbox credentials arrive through the one-time secure key exchange. MarsX also issues a second sandbox client with no scope, for test P16.
- MarsX confirms each result by email to your technical contact, using the test numbers below (P for automatic sending, R for catch-up). You reply with your own results in the same format.
- A sandbox connection reference and key never work in production. Production has its own.
Cause a fault on purpose
Section titled “Cause a fault on purpose”In the sandbox only, add a request header to make MarsX misbehave on that one request. Production ignores the header.
| Header | What MarsX does |
|---|---|
x-marsx-sandbox-scenario: 503 |
Answers 503 to that one request |
x-marsx-sandbox-scenario: drop-202 |
Stores the event, then drops the 202 |
Use these to prove that your retry logic works.
Automatic sending tests
Section titled “Automatic sending tests”The POST answer shows intake only. Check the business outcome with the status call, or ask MarsX to confirm it from its side.
| # | Test | POST answer |
Outcome and where to check it |
|---|---|---|---|
| P1 | A lead.new with a valid signature and a timestamp inside the window |
202 accepted |
applied. The lead is present on the MarsX side |
| P2 | A request with an invalid signature | 401 |
No lead data stored. Nothing enters processing |
| P3 | A request with a timestamp outside the window | 401 |
No lead data stored. Nothing enters processing |
| P4 | The same event sent twice with the same webhook-id |
First 202 accepted. Second 202 duplicate |
One stored event and one outcome |
| P5 | A new webhook-id with a lower source_revision (new or update) |
202 accepted |
ignored_stale_revision. The state does not go back |
| P6 | A higher revision | 202 accepted |
applied. All fields you own are replaced |
| P7 | The same revision with different content | 202 accepted |
quarantined, reason revision_conflict |
| P8 | A lead.withdraw |
202 accepted |
A pending restriction is on the lead as soon as the event is stored. Then applied |
| P9 | A lead.withdraw with an older revision than the stored one |
202 accepted |
applied |
| P10 | A lead.update after a withdrawal |
202 accepted |
quarantined, reason privacy_marker_set. Personal data is not restored |
| P11 | Rejected bodies: wrong content type, an authenticated body above 32,768 bytes and below 1 MiB, a missing required field, a contact field on a lead.withdraw |
415, 413, 400, 400 |
No lead data stored. A restricted rejection record with no customer data. The refused lead.withdraw alerts the MarsX privacy operator |
| P12 | A key rotation with both signatures in the header | 202 accepted with the old key, with the new key and with both |
No event lost |
| P13 | An event sent with x-marsx-sandbox-scenario: 503 |
503, then 202 accepted on a retry |
You retry with backoff and jitter. The event is stored once and processed once |
| P14 | The same revision with identical content, under a new webhook-id |
202 accepted |
ignored_duplicate_content |
| P15 | An event sent with x-marsx-sandbox-scenario: drop-202 |
The first POST is stored and its 202 is dropped. The retry with the same webhook-id, the same body and a fresh timestamp and signature gets 202 duplicate |
One stored event and one outcome |
| P16 | The status call: read the status of the P1 event, of an unknown webhook-id, and with the client that has no scope |
200 with the state, 404, 403 |
Confirms the states used above |
| P17 | A POST with no signature to a made-up connection path, and the same POST to the real path. Repeat with a wrong signature |
401 for both. The two answers are identical: status, headers (apart from Date and the request ID) and body |
No lead data stored. Someone who is not authenticated cannot tell which connections MarsX has |
| P18 | A correctly signed event for connection A, sent to the path of connection B (two made-up connections). Then the same partner_lead_id and webhook-id sent to both connections, each with its own key |
401 for the first. 202 accepted for each of the second |
Nothing stored for the first. The two leads are independent |
| P19 | A lead.update for a lead MarsX has not seen |
202 accepted |
applied as the first state of the lead |
| P20 | A lead.delete with a new request_reference, after a lead.withdraw of the same lead |
202 accepted |
applied. Not set aside |
| P21 | An unauthenticated body of 2 MiB to the events path, and the same to a made-up path | 413 for both, identical, before authentication |
Nothing stored. The answer reveals nothing about connections |
| P22 | An unauthenticated body of 11 MiB to the file upload path | 413 before authentication |
Nothing stored |
| P23 | An authorised, correctly signed template file between 1 MiB and 10 MiB sent to the file upload path | 201 stored |
The file enters file processing. The file route cap is 10 MiB, not the 1 MiB of other paths |
| P24 | A lead.new with the required fields only: no source_revision, no updated_at, no model_interest, no customer_city |
202 accepted |
applied as revision 1, with updated_at equal to created_at. The lead is marked incomplete |
| P25 | A lead.new without contact_consent, one with contact_consent set to no, and a lead.update without source_revision |
400 for each |
No lead data stored. A restricted rejection record with no customer data |
Catch-up tests for automatic sending
Section titled “Catch-up tests for automatic sending”You host three read-only endpoints. MarsX runs these tests against them.
| # | Test | Expected result |
|---|---|---|
| R1 | Hold back three sends: one new lead, one deletion of a lead you no longer hold, and one withdrawal with an older revision than the stored lead | Nothing arrives by automatic sending |
| R2 | MarsX runs its catch-up check | The missing lead is found through changes and fetched. The deletion and the withdrawal come from privacy-events, are applied, and both leads are restricted |
| R3 | Read privacy-events for an erased lead ID |
The erasure is listed, although you no longer hold the lead |
| R4 | Page through changes with several rows sharing one updated_at, stopping and restarting between pages |
No row is lost or repeated |
| R5 | Send the sandbox cursor that always returns 410 to privacy-events, and another to changes |
privacy-events: 410. MarsX keeps the leads of the connection restricted for contact. Starting again from the oldest item held does not lift the restriction by itself. changes: 410. MarsX starts again from the oldest item held and shows the connection as not verified for completeness |
| R6 | Read one lead with leads/{partner_lead_id} |
The same data shape as the automatic sending events |
| R7 | Keep a deletion older than the replay window. It is on the full privacy list but no longer in privacy-events. Expire the cursor and run the recovery with the full list |
After recovery, the lead ID carries the privacy marker and is restricted. The deletion is honoured. The connection restriction lifts only when recovery is proven |
Upload checks
Section titled “Upload checks”Run these with made-up files before activation. They apply to automatic file upload and to upload by staff.
- A made-up file with a valid row and an invalid row. The valid row loads and the invalid one is set aside with a reason.
withdrawanddeleterows, including one for an erased lead ID.- A skipped file sequence. MarsX flags it and keeps the connection restricted.
- The skipped file is sent again, unchanged. The privacy rows are applied. The restriction lifts only after the gap closes.
- The same file sent twice. Nothing changes.
- A file with the wrong
template_versionor the wrong row count. The whole file is rejected. - A file with a macro, a formula or an external link, or a file over a content limit. The whole file is rejected.
- A correctly signed file of 2 MiB is accepted. An unauthenticated body of 11 MiB gets
413before authentication (tests P22 and P23). - Upload by staff: a staff account uploads a made-up file and sees only the outcome and the row counts. Sign-in without the authenticator code fails. An account of another connection cannot see or upload to this one.
Capability review for automatic sending
Section titled “Capability review for automatic sending”If any of C1 to C4 is missing, the connection cannot go live on automatic sending.
| # | Capability | Evidence |
|---|---|---|
| C1 | Signed sending | Tests P1 to P3 |
| C2 | Changed-since query with a stable cursor that handles equal timestamps | Tests R2 and R4 |
| C3 | Privacy-event replay, including an erased lead ID | Tests R2, R3 and R7 |
| C4 | Single-lead fetch of the current state | Test R6 |
| C5 | A published OpenAPI 3.1 document with a webhooks section, and a sandbox with made-up data |
The document and the sandbox address |
| C6 | Pull calls that use OAuth 2.0 client credentials, a read-only scope and short-lived tokens | A token request and a read call |
Go live
Section titled “Go live”Go live in this order. Both sides confirm each step.
- The tests pass. You run every test case above that applies to your route. MarsX confirms each result by email, and you reply with your own results in the same format. Test numbers are P and R. MarsX confirms in writing that the sandbox tests passed.
- MarsX sends the production details. MarsX sends the production connection reference and keys through a new one-time secure link, separate from the sandbox one. For automatic sending, you also give MarsX your production base address and token address.
- You switch the configuration. Change the base address to
https://api.marsx.media, and the connection reference and keys to the production ones. Sandbox keys never work in production, and production keys never go into the sandbox. - You send the first real lead. Then confirm with MarsX that it arrived. With automatic sending, check it with the status call. With a file route, MarsX emails the result of the file.
- MarsX confirms in writing that the connection is active. MarsX shows the route of the connection. It shows a file connection as file-fed, and never as real time.
Checklist before step 3
Section titled “Checklist before step 3”Both sides confirm every line.
- Tests P1 to P15, P17 to P25 and R1 to R7 passed, with the results confirmed by email on both sides. Also P16, when you use the status call. (Automatic sending.)
- The upload checks passed. (Automatic file upload and upload by staff.)
- Capabilities C1 to C6 shown. (Automatic sending.)
- Your operators can see failed deliveries and can re-send one.
- Both sides named a technical contact and an escalation contact.
- If your own dashboard shows the same leads, it shows them read-only or hides their status fields.
Never do this
Section titled “Never do this”Never send a lead file by email or WhatsApp
MarsX does not accept it. Use the signed upload or the staff upload account.
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 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.
Never treat anything but 202 as confirmation
A timeout, 429, 502, 503 or 504 does not confirm anything. Retry the same event.
