Support API

Keeping your users in sync with Gauge Support

This is the integrator's guide to the customer sync API. It's written for a SaaS backend (Salonvira is the first one) that owns its users and wants Gauge Support to mirror them: who exists, what…

This is the integrator's guide to the customer sync API. It's written for a SaaS backend (Salonvira is the first one) that owns its users and wants Gauge Support to mirror them: who exists, what they can reach, whether they're allowed in, and a few facts agents should see.

All routes are under /v1/support and authenticate with Authorization: Bearer pulse_k_…. Reads need support:read; writes need support:rw. A key pinned to projects (projectIds at mint) only sees and changes customers in those projects. Everything else returns 404. A test key (pulse_k_test_) is always pinned to the workspace's sandbox project, so it only sees sandbox customers; a live key never sees a customer whose every grant is on the sandbox (by ref it is a 404, by email a 422 email_unavailable), and never reads or reconciles a sandbox grant. Use different test emails and refs from your production ones, or reset the sandbox.

1. The key is your user id

Every customer is addressed by externalRef: your own stable user id, as a string of 1–200 chars. Use the id that never changes. Don't use the email.

You want to…Call
create or update one userPUT /customers/by-ref/{externalRef}
create or update up to 500 usersPOST /customers/sync
look one upGET /customers?externalRef=… or GET /customers/{id}
deactivate, reactivate or block manyPOST /customers/status
pull every change since time TGET /customers?updatedSince=T

Fields you can set

fieldmeaning
emailRequired to create. Changing it on an existing ref moves the identity (see §3).
name, localeDisplay name and BCP-47-ish locale (ar, en-GB). null clears.
statusactive · deactivated (read-only, see §4) · blocked (full lockout).
projectIdsAuthoritative when present: we add missing grants and revoke extra ones, within your key's projects. Omit it to leave access untouched.
metadataYour facts, shown to agents (§5). Replaced wholesale when present; omit to leave it.
attrsVerified attributes your server vouches for (tier, role, plan …). Up to 20 keys, scalar values (string ≤120, number, boolean). Agents cannot edit them; SLA by tier reads attrs.tier when the customer has no organization tier. Replaced wholesale when present. GDPR erase clears them.
organizationThe customer's company: {"externalRef":"acc-42","name":"Acme","tier":"enterprise"}. Upserted by your account id; tier (lower-cased slug) picks the customer's SLA policy (Team → SLA & escalation). Omit tier to keep the stored one, null to clear it. "organization": null removes the customer from it. A customer has one synced organization; naming another replaces it. A project-pinned key can create or join an organization but needs a workspace-wide key to change an existing one's name or tier (forbidden). Membership never merges people and never widens what a customer sees in the portal.

2. Batch sync: POST /customers/sync

{ "customers": [
  { "externalRef": "u_1029", "email": "layla@example.com", "name": "Layla", "locale": "ar",
    "projectIds": ["<project uuid>"], "metadata": { "plan": "pro", "branch": "Riyadh" } },
  { "externalRef": "u_2210", "status": "deactivated" }
] }
  • Up to 500 items per call. Each externalRef may appear only once per call.
  • Each item stands alone. One bad row never fails the batch. You get a result per item, in order:
{ "runId": "…", "counts": { "created": 1, "updated": 0, "unchanged": 1, "error": 0 },
  "results": [
    { "externalRef": "u_1029", "customerId": "…", "outcome": "created",   "error": null },
    { "externalRef": "u_2210", "customerId": "…", "outcome": "unchanged", "error": null }
  ] }
  • Outcomes:
    • created: a new customer.
    • updated: something changed.
    • unchanged: nothing differed. Nothing is written, no event fires, and the cursor doesn't move.
    • error: see error.code.
  • Idempotent twice over. Re-sending the same batch converges to unchanged. If you also send an Idempotency-Key header (recommended for scheduled jobs), a retry with the same key and body replays the first response exactly. Reusing the key with a different body returns 409.
  • Audit trail. Every call writes one support_customer_sync_runs row: who ran it, the counts, and per-item ids, outcomes and error codes. It never stores an email, a name or a metadata value.

Per-item error codes (stable; branch on these)

codewhywhat to do
email_requiredunknown externalRef and no emailsend the email to create
email_in_usethat email belongs to a customer with a different externalRefsee §3; we never merge two of your users
email_unavailable(project-pinned keys only, instead of email_in_use) the address can't be used with this key. It is 422 like email_required, and it never says whether the address is held or by which projectuse another address, or resolve it with a workspace-wide key
project_not_founda projectIds entry isn't a project this key may usefix the id, or the key's pin
forbiddena project-pinned key tried to change the email of a customer that another project also serves, or to set such a customer back to active (erase is refused the same way, 403)do it with a workspace-wide key
metadata_too_largemetadata is over 8 KB as stored (see §5). On PUT/PATCH it is 400 with error.code: "validation_failed", error.reason: "metadata_too_large"trim the metadata; the batch's other items are unaffected

Pinned keys and shared customers. Suppose a project-pinned key sets status: blocked or deactivated on a customer that another project also serves. The customer's status does not change, because it applies to every project. Instead, the customer loses access to your projects: grants and the portal sessions on them are revoked. The item answers updated with scope: "project". After that, the customer is outside your key's projects, so it reads as 404. | not_found | (status batch) unknown ref/id, or outside your key's projects | — | | internal | unexpected failure for that item | retry the item |

PUT /customers/by-ref/{ref} follows the same rules for a single user. It answers 201 {outcome:"created"} or 200 {outcome:"updated"|"unchanged"}, plus the record. An item error becomes the HTTP status (422 email_required, 409 email_in_use, or 422 email_unavailable for a pinned key, 404), with the code in error.reason. POST /customers, a ticket's requesterEmail and a session link answer a pinned key the same 422 email_unavailable.

3. Conflict rules

  1. externalRef wins. If the ref is known, we update that customer, whatever the email says.
  2. Email change on a known ref. The address and its identity move to the new email. Email verification is reset, so the customer proves the new address the first time they sign in by magic link. Tickets and history stay with the customer. If the new email already belongs to another customer, the item fails with email_in_use.
  3. New ref with an email we already know:
    • without an externalRef (they signed up through the portal, or you created them by email): we adopt them. Your ref is attached and the outcome is updated.
    • with a different externalRef: email_in_use. Two of your users are never merged. Fix the duplicate on your side, or change one user's email first.
  4. Concurrency. Writes to one externalRef are serialized server-side, so two jobs syncing the same user can't create two customers.

4. Status: active, deactivated, blocked

statusportalnew ticket / chat / escalation / emailed threadlive sessions
activefullallowedkept
deactivatedread-only: sees existing tickets, can't reply, upload, rate or callrefusedkept (read-only)
blockednonerefusedrevoked immediately, including open sockets

Moving to active again (from either state) reverses it. Each change fires a webhook to the customer's projects: support.customer.deactivated, support.customer.blocked or support.customer.unblocked. Use POST /customers/status to change many at once:

{ "status": "deactivated", "externalRefs": ["u_2210", "u_3301"] }

Use POST /customers/{id}/sessions/revoke to sign someone out everywhere without changing their status.

5. Metadata

A flat object of your own facts, shown to agents next to the customer's tickets: plan, tier, branch, salonId and so on.

  • Up to 50 keys. Keys match [A-Za-z0-9_.-], max 64 chars.
  • Values are a string (≤500 chars), number, boolean, null, or an array of up to 50 scalars.
  • At most 8 KB as stored. That is the database's JSON text form, which puts a space after every : and ,, so it is a little larger than compact JSON. Allow about 2 bytes per key and array element. Over the limit the item answers metadata_too_large, and the rest of the batch still applies.
  • Text values cannot contain the NUL character (\u0000) or unpaired UTF-16 surrogates. The database cannot store them, so the request is a 400 validation_failed.
  • Don't put secrets, payment data, health data or anything regulated in it. It's visible to every agent with access to support. GDPR erase clears it.
  • A change fires support.customer.updated with fields: ["metadata"]. The values themselves are never sent on webhooks.

6. The nightly reconcile

The batch sync pushes your changes to us. The reconcile read lets you verify them and pick up changes made on our side (an agent blocks someone, a customer is erased, access is revoked in the console):

GET /v1/support/customers?updatedSince=2026-10-09T00:00:00Z&limit=500
→ { "rows": [ … ], "nextCursor": "…" | null, "highWatermark": "2026-10-10T02:14:07.123456Z" }
  • Rows come back in ascending updatedAt, then id order, at most 500 per page.
  • updatedAt moves on every change to a field in the record, including a project grant or revoke and an erasure. It's maintained by the database, so no writer can forget it. Changes we keep internally, such as linking the customer to a CRM contact, don't move it, so they don't wake your reconcile.
  • Follow nextCursor until it's null. The cursor is opaque and microsecond-exact, so a page boundary never skips or repeats a row. A malformed cursor returns 400, not a silent restart.

The algorithm:

  1. Load W, the watermark you stored last time (on the first run, use your epoch).
  2. Call GET /customers?updatedSince=W − 5 minutes&limit=500 and follow nextCursor to the end. The 5-minute overlap covers clock skew and in-flight writes. Overlapping rows simply compare equal.
  3. For each row, compare with your user externalRef. Repair whichever side is wrong (usually by putting the item in your next POST /customers/sync).
  4. Store the final page's highWatermark as the new W only after step 3 succeeds. Store it exactly as returned, and don't subtract the overlap before you store it. The 5 minutes come off at read time, in step 2. On a page with rows, highWatermark is the last row's updatedAt. On an empty page, it's our clock when the query started, and never earlier than your updatedSince. So a quiet night still moves W forward, and repeated empty runs don't drift it backwards.
  5. Push your own changes with POST /customers/sync (with an Idempotency-Key per batch) and check every error result.

If a run crashes, nothing is lost: you resume from the old W.

7. Webhooks you'll want

Subscribe your endpoint to support.customer.*. Every body carries a stable id (the event id). It stays the same across retries and resends, so dedupe on it. Reject a request whose x-pulse-timestamp is more than 5 minutes from your clock. Signature details are in the OpenAPI document (GET /openapi.json).

On this page