Base URL: https://api.geoswap.co/v1. The machine-readable contract is https://api.geoswap.co/v1/openapi.json (OpenAPI 3.1). Step-by-step setups for Salesforce, HubSpot and Zapier are in the recipes.
Attribution reports built on these conversions are coming to the dashboard. Conversions you send now are stored and will be attributed when they ship.
Create a key in Settings → API keys (workspace owners and admins). A key belongs to one workspace and carries scopes: workspace:read and/or conversions:write. Send it as Authorization: Bearer gs_live_…. Keys are shown once; revoke one at any time. API access works on every plan.
Paths below are relative to https://api.geoswap.co/v1.
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /workspace | workspace:read | The key's workspace, install snippet and sites. Use it to verify a key. |
| POST | /conversions | conversions:write | Send one conversion. |
| POST | /conversions/batch | conversions:write | Send up to 500 conversions; each succeeds or fails on its own. |
One stage of a journey: a lead, a qualified lead, a signed deal, a purchase. A first send answers 201 with result: "created"; sending the same event and external_id again answers 200 with updated or unchanged. Fields you leave out keep their stored value. An empty string or null in an optional field is ignored.
curl -X POST https://api.geoswap.co/v1/conversions \
-H "Authorization: Bearer $GEOSWAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event": "qualified",
"external_id": "006Hs00001AbCdE",
"parent_external_id": "00QHs00002XyZqR",
"occurred_at": "2026-10-12T14:03:00Z",
"visitor_id": "Xb3k9_Qm2LpA7vR1.20261003",
"email": "jane@example.com",
"value": 1200,
"currency": "USD",
"tags": {
"market": "co"
}
}'| Field | Type | Required | Notes |
|---|---|---|---|
event | string | Yes | Stage name: lead, qualified, signed, purchase… Letters, digits and _, up to 40. Stored lower-case. |
external_id | string | integer | Yes | Your id for this conversion (lead id, order id), up to 128 chars. A JSON number is accepted too (some CRMs send ids that way) and stored as its decimal string. Sending the same event and external_id again updates it. |
occurred_at | string | integer | Yes | When it happened: ISO 8601 with a time zone (2026-10-12T14:03:00Z) or epoch milliseconds. Any age: backfills are welcome, but conversions older than 90 days can't be matched to visits. |
parent_external_id | string | integer | No | The external_id of an earlier stage, e.g. the lead behind this deal. A JSON number is accepted too. Links the stages into one journey, in any arrival order. |
visitor_id | string | No | The gs_vid cookie value, or the hidden gs_vid form field that geoswap.js fills in. |
click_id | string | No | The GeoSwap click id from the hidden gs_c form field. |
contact_id | string | No | Your CRM contact id. Also matches gs_r on per-recipient links. |
email | string | No | Hashed on receipt (SHA-256 of the trimmed, lower-cased address). Never stored or logged raw. |
phone | string | No | Normalized to E.164 (send a + prefix or country) and hashed. Never stored raw. |
country | string | No | ISO 3166-1 alpha-2, e.g. US. Also the phone's default country. |
address | object | No | { line1, zip, country? }. Normalized and hashed; never stored raw. |
value | number | string | No | Value in currency units, e.g. 49.99. |
currency | string | No | ISO 4217, e.g. USD. |
hdyhau | string | No | "How did you hear about us?" answer, up to 200 chars. |
tags | object | No | Up to 5 string labels, e.g. {"site": "diara", "market": "in"}. |
Email, phone and address are normalized and SHA-256 hashed the moment they arrive; GeoSwap never stores or logs them raw. An identifier that can't be read is dropped and named in warnings; the conversion is still saved.
external_id and parent_external_id also accept a JSON number (HubSpot's Record ID is one); it's stored as its decimal string, so the same id sent as 123 or "123" is the same conversion.
POST /conversions/batch with { "conversions": [ … ] }. A valid batch always answers 200 with one result per item, in order, plus a summary; each item succeeds or fails on its own. An invalid envelope (wrong shape, over 500 items, over the size limit) or an auth/rate-limit failure answers before any item is written, with its own status (400/401/403/413/422/429) instead.
A batch is written in chunks of 100. If a later chunk fails, earlier chunks may already be committed and the request returns 500 — re-send the whole batch. That's safe: GeoSwap matches on event + external_id, so already-written items are just updated again.
curl -X POST https://api.geoswap.co/v1/conversions/batch \
-H "Authorization: Bearer $GEOSWAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"conversions": [
{
"event": "qualified",
"external_id": "006Hs00001AbCdE",
"parent_external_id": "00QHs00002XyZqR",
"occurred_at": "2026-10-12T14:03:00Z",
"visitor_id": "Xb3k9_Qm2LpA7vR1.20261003",
"email": "jane@example.com",
"value": 1200,
"currency": "USD",
"tags": {
"market": "co"
}
},
{
"event": "purchase",
"external_id": "order-1001",
"occurred_at": "2026-10-12T15:20:00Z",
"visitor_id": "Pq7Lm2Xr9Ka0Zt3W",
"value": 49.99,
"currency": "USD",
"tags": {
"site": "diara",
"market": "in"
}
}
]
}'Add hidden inputs named gs_vid, gs_c and gs_r to your forms. geoswap.js fills them in (it never adds inputs), so the values land on the CRM record. Send them back as visitor_id, click_idand contact_id/gs_r. Forms inside cross-origin iframes (some HubSpot embeds) can't be filled; send the email instead.
There's no self-serve delete endpoint yet. Email support@geoswap.co with the visitor id, email, phone or contact id that identifies the person, and GeoSwap deletes their visit history (touches), erases the identifiers on their conversions, and removes them from attribution. Conversion counts stay; the person can't be identified from them. A deletion removes what is stored now: if your CRM sends the same conversion again later, it is stored as new data. Stop syncing a person who asked to be forgotten.
Every error is { "error": { "code", "message", "request_id" } }. code never changes meaning; request_id equals the X-Request-ID header. Quote it to support.
| Code | Status | Meaning |
|---|---|---|
invalid_json | 400 | The body isn't valid JSON. |
query_not_allowed | 400 | Write endpoints take every field in the body. Query strings end up in access logs. |
missing_api_key | 401 | No Authorization header with a gs_live_ key. |
invalid_api_key | 401 | Unknown or revoked key. |
workspace_suspended | 403 | The workspace is suspended. |
insufficient_scope | 403 | The key lacks the scope this endpoint needs. |
workspace_not_found | 404 | The key's workspace no longer exists. |
not_found | 404 | Unknown path: not a v1 endpoint. Includes any path on api.geoswap.co other than /v1/*, / and bare /v1. |
payload_too_large | 413 | Body over 32 KiB (single) or 1 MiB (batch). |
validation_failed | 422 | details lists each bad field (never its value). |
occurred_at_out_of_range | 422 | occurred_at is in the future (more than 5 minutes ahead) or before 2000. |
rate_limited | 429 | Too many requests or conversions this minute; wait Retry-After seconds. |
internal_error | 500 | Our fault. Retry; quote request_id to support@geoswap.co if it persists. |
A 401 also carries a WWW-Authenticate header (RFC 6750); a 403 from insufficient_scope names the missing scope there too.
Per API key: 600 requests and 30,000 conversions per minute, in fixed one-minute windows (a batch counts each conversion). Over the limit you get 429 rate_limited with a Retry-After header. Wait the Retry-After seconds, then retry.
/v1 only changes additively: new endpoints, new optional fields, new response fields, new error codes. Ignore fields you don't know. Breaking changes ship as /v2, with at least six months' notice before v1 stops.