Sand

Developer guide

Your records, in and out of Sand.

Everything the Sand app does with your data, your own systems can do too — pull contacts into a warehouse, push leads in from a website form, run enrichment on a schedule. One key, one header, and the same records you see on screen.

No SDK to install. If your tool can make an HTTPS request, it can talk to Sand.

The full reference lists every endpoint with its fields, examples in several languages, and a panel to try each one with your key. The OpenAPI file imports straight into Postman.


Create a key

In Sand, open Settings → API keys and choose Create key. Give it a name you will recognise in six months — the system it is for, not “key 1” — and tick only the scopes that system actually needs.

The key is shown once. Sand stores only a hashed copy, so it cannot be shown again or recovered — if it is lost, revoke it and make another. Copy it straight into your configuration, not into a chat or a ticket.

A key looks like sand_ followed by 64 characters. It can be revoked at any time from the same screen, and it stops working immediately.

Your first call

Send the key in the x-api-key header. That is the whole of it:

curl https://api.yourdomain.com/api/v1/crm/contacts \
  -H "x-api-key: sand_xxxxxxxxxxxxxxxx"

You get your contacts back, newest first, in pages:

{
  "success": true,
  "data": {
    "contacts": [ { "id": 182, "firstName": "Anita", "lastName": "Rao", … } ],
    "total": 1463,
    "page": 1,
    "pages": 59
  }
}

Every address starts /api/v1, and those are the only ones a key opens. The header is the only place Sand accepts a key — never put it in the web address, where it would be written into every server log it passes through.

What your plan includes

API access comes with Grain · Enrichment, Base + Grain and Base + Grain + Wave. It covers the modules your plan covers — nothing more, nothing less — and there is no separate API charge.

PlanAddressesWhat a key can reach
Grain · Enrichment /v1/enrichment The company database: search it, match your own list to it, reveal the people at a company, and your credits
Base + Grain both The database, plus /v1/crm — your contacts, companies, deals and leads — and filling in your CRM companies from the database
Base + Grain + Wave all three Both, plus /v1/outreach — campaigns, the people in them, and meetings

Base · CRM on its own does not include the API: a key cannot be made, and every call answers 403 with the code PLAN_NO_API.

Wave is a talk-to-us tier, so outreach arrives with the plan rather than off the price list. Once your workspace has it, its endpoints behave exactly like the rest.

Scopes

Each key is granted only the parts of the API it needs. A website form that creates leads has no business spending enrichment credits, even though the person who made the key could.

ScopeLets the key
crm:readRead your records
crm:writeCreate, change and delete them
enrichment:readSearch and match the database, and see credits — all free
enrichment:runReveal people and enrich companies — the calls that spend credits
outreach:readRead campaigns, their members and meetings, on a plan that includes Wave

Two rules worth knowing. A scope can only narrow what a key reaches — it can never open a module your plan does not include. And a key created with no scopes at all is read-only, not unrestricted.

A key acts as the person who created it: their workspace, their permissions. It cannot see another workspace’s records, and if that person loses access, the key goes with them. Account settings, billing and key management itself are closed to keys entirely.

Reading and writing records

Contacts are shown here; companies, deals and leads work the same way at /v1/crm/companies, /v1/crm/deals and /v1/crm/leads — list, create, get, update, delete.

Pull a page at a time

curl 'https://api.yourdomain.com/api/v1/crm/contacts?page=1&limit=100' \
  -H "x-api-key: $SAND_KEY"

Up to 100 per page. Keep asking until page reaches pages.

Push one in

curl -X POST https://api.yourdomain.com/api/v1/crm/contacts \
  -H "x-api-key: $SAND_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "firstName": "Anita",
    "lastName": "Rao",
    "email": "anita@acme.example",
    "phone": "+91 80 2222 3333",
    "companyName": "Acme Agri",
    "title": "Head of Procurement"
  }'

The answer carries the new record and, if Sand spots someone who looks like the same person, a possibleDuplicates list. That is a warning, not a refusal — the record is created either way, so your sync can log it and move on.

Push up to a hundred at once

For a first load or a nightly sync, send a batch rather than a request per person. The answer says, row by row, what was created and what was skipped and why — so your sync can retry the failures without sending what already landed.

curl -X POST https://api.yourdomain.com/api/v1/crm/contacts/bulk_create \
  -H "x-api-key: $SAND_KEY" -H 'Content-Type: application/json' \
  -d '{"contacts":[
        {"firstName":"Anita","email":"anita@acme.example"},
        {"firstName":"Ravi","email":"ravi@harvest.example"}
      ]}'

A row whose email already belongs to one of your contacts is skipped with that contact’s id rather than duplicated, so sending the same batch twice adds nobody twice.

Enrichment

Enrichment is the metered part, and only revealing a person or enriching a company spends credits. Searching and matching are free, and nothing is ever charged for an empty result.

Find companies

curl -X POST https://api.yourdomain.com/api/v1/enrichment/companies/search \
  -H "x-api-key: $SAND_KEY" -H 'Content-Type: application/json' \
  -d '{"q":"seeds","states":["Karnataka"],"sizes":["11-50"],"has":["email"]}'

Match your own list

Your systems hold domains and GSTINs, not Sand’s ids. Send up to 25; a name that several companies share comes back with candidates to choose from rather than a guess.

curl -X POST https://api.yourdomain.com/api/v1/enrichment/companies/match \
  -H "x-api-key: $SAND_KEY" -H 'Content-Type: application/json' \
  -d '{"companies":[{"domain":"acme-agri.example"},{"gstin":"29AAAAA0000A1Z5"}]}'

Reveal, and fill in your CRM

# a person's email or phone — charged once, never for an empty one
curl -X POST https://api.yourdomain.com/api/v1/enrichment/people/reveal \
  -H "x-api-key: $SAND_KEY" -H 'Content-Type: application/json' \
  -d '{"ref":"p123","channel":"email"}'

# your own companies, filled in from the database
curl -X POST https://api.yourdomain.com/api/v1/crm/companies/enrich \
  -H "x-api-key: $SAND_KEY" -H 'Content-Type: application/json' \
  -d '{"ids":[12,15]}'

Nothing is guessed: a field the database cannot back up stays empty rather than being filled in.

Limits

Ask, rather than finding out the hard way. This works with any valid key, whatever its scopes:

curl https://api.yourdomain.com/api/v1/usage \
  -H "x-api-key: $SAND_KEY"

It tells you the key’s scopes, your plan, your credit balance and your request budget — 3,000 requests every 15 minutes per key, counted per key, so one integration cannot eat another’s allowance. Every response also carries RateLimit- headers with what is left in the current window.

When something is refused

CodeWhat it meansWhat to do
401 The key is unknown, revoked or expired Check it was copied whole; make a new one if it was revoked
403 The key is valid but missing a scope — the message names which Add that scope, or use a key that has it
403 Your plan does not include API access (PLAN_NO_API) Move to a plan with Grain — see what your plan includes
403 Your plan does not include that module See what your plan includes
429 Over the request budget Wait for the window to reset, then retry — spread bulk work out
503 Sand could not check the key just then Retry shortly. Your key is almost certainly fine

Every refusal answers in the same shape, with a plain-language message you can log.

Keeping a key safe

A key is a password that will sit in a configuration file for years. Four habits cover most of it:

  • Keep it in an environment variable or a secret store — never in your source code, and never in a web address.
  • One key per system, so you can revoke the one that leaked without stopping everything else.
  • Grant the fewest scopes that work. A read-only key cannot damage anything.
  • Set an expiry on anything temporary, and revoke keys for systems you have retired.

If a key is ever exposed, revoke it first and investigate second — revoking takes effect immediately, and making a replacement takes a few seconds.

Something missing?

If you need an endpoint that is not here, or a webhook rather than a poll, tell us what you are building — we would rather hear it than have you work around it.

Open the full reference Create an account