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.
| Plan | Addresses | What 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.
| Scope | Lets the key |
|---|---|
crm:read | Read your records |
crm:write | Create, change and delete them |
enrichment:read | Search and match the database, and see credits — all free |
enrichment:run | Reveal people and enrich companies — the calls that spend credits |
outreach:read | Read 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
| Code | What it means | What 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.