Skip to content

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.

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:

  1. Node.js 18 or later, or Python 3.9 or later. The examples need no extra packages.
  2. A sandbox connection reference. It looks like conn_ followed by 16 letters and digits. The examples use conn_exampleexample22, which is made up.
  3. 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.

Save this as lead.json in an empty folder. Every value is made up.

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

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.

send-lead.mjs
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_exampleexample22
const 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());

Set the two variables and run the script.

Terminal window
export MARSX_CONNECTION="conn_exampleexample22" # use your own sandbox reference
export MARSX_SIGNING_SECRET="whsec_..." # use your own sandbox secret
node send-lead.mjs

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. |

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:

Terminal window
MARSX_WEBHOOK_ID=msg_tutorial_0001 node send-lead.mjs
MARSX_WEBHOOK_ID=msg_tutorial_0001 node send-lead.mjs

The 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.

  • 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.

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.

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

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

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.
  • withdraw and delete rows, 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_version or 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 413 before 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.

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 in this order. Both sides confirm each step.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

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 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.