Documentation/Integrations
API Keys
Call the Engage API from your own code, without a signed-in session.
An API key lets your own code call Engage directly — send a push, read a segment, add a contact — with no person signed in. You create a key once, store it in your application's configuration, and send it on every request.
A key acts on your account with your permissions. Treat it like a password: keep it in environment variables or a secret manager, never in front-end code, a public repository, or a URL.
1. Create a key
Open API Keys
From the sidebar, open API Key.
Click Create Key
Give it a name that says what will use it, e.g. "Casino site — push sender". You cannot revoke what you cannot identify.
Copy the key immediately
The full key is shown once. Engage stores only a hash of it, so it can never be shown again — if you lose it, revoke it and create another.
If you do not see API Key in the sidebar, API access has not been switched on for your company yet — ask your account manager. It is not part of any plan, so upgrading will not add it; an administrator enables it per company.
2. Authenticate
Send the key in the Authorization header on every request. Every key starts with eng_live_.
Authorization: Bearer eng_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThe base URL is your Engage API host followed by /api/v1. Set both once and the examples below work as written:
export ENGAGE_API_KEY='eng_live_...'
export ENGAGE_API='https://api.engage.nevtan.com/api/v1'A quick check that the key works — it touches authentication, the account and permissions in one call:
curl "$ENGAGE_API/push/segments" \
-H "Authorization: Bearer $ENGAGE_API_KEY"Every response uses the same envelope, so you can handle success and failure the same way everywhere:
{
"status": "success",
"message": "",
"data": [ ... ]
}3. Create a contact
POST /api/v1/contacts is the simplest complete example — nothing to look up first. It needs the contacts:write permission.
curl -X POST "$ENGAGE_API/contacts" \
-H "Authorization: Bearer $ENGAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "apikey@example.com",
"first_name": "api",
"last_name": "testing",
"phone": "+919876543210"
}'Every field is optional. Two more are useful: external_id to carry your own identifier for the person, and list_id to drop them straight into a list in the same call.
{
"status": "success",
"message": "",
"data": {
"_id": "000000000000000000000001",
"email": "apikey@example.com",
"first_name": "api",
"last_name": "testing",
"phone": "+919876543210",
"subscribed": true,
"sms_consent": { "status": "subscribed", "source": "manual" },
"created_at": "2026-01-01T00:00:00Z"
}
}Check status, not the HTTP code. A duplicate email comes back as HTTP 200 with "status": "failure", not a 409. Code that only tests response.ok will record a rejected contact as created.
Three behaviours worth knowing
- Duplicate emails are rejected across the whole system, not just your account. If that address exists anywhere, you get
Contact with this email already exists. Bulk imports run into this. - Sending `phone` opts the contact into SMS. A contact added this way is treated as a manual, consented add, so SMS consent is recorded as subscribed. If you are importing numbers you do not have consent for, leave
phoneout on create and add it with aPATCHafterwards. - The email is not validated. It is stored as given, so
not-an-emailis accepted.
Read them back with GET /api/v1/contacts, which needs only contacts:read.
4. Send a push
POST /api/v1/push/send needs campaigns:write. Pass a segmentId taken from /push/segments above, or leave it out to reach every live device on the account.
curl -X POST "$ENGAGE_API/push/send" \
-H "Authorization: Bearer $ENGAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Your bonus is live",
"body": "Claim it before midnight.",
"segmentId": "PASTE_A_SEGMENT_ID_HERE",
"link": "https://example.com/promo",
"dryRun": true
}'Keep dryRun set to true while you are wiring this up. The message is fully validated and real errors come back, but nothing is delivered to anyone. Remove it when you are ready to send for real.
The response tells you what happened, with no invented certainty about delivery:
{
"status": "success",
"message": "sent",
"data": {
"campaignId": "000000000000000000000002",
"segmentName": "Active players",
"contactsInSegment": 4120,
"reachableContacts": 3874,
"total": 4192,
"accepted": 4180,
"retired": 12,
"dryRun": false
}
}| Field | What it means |
|---|---|
total | Devices the message was addressed to. One person with three devices counts three times. |
accepted | Devices the push service took responsibility for. Not a delivery count — what happens after that is invisible to us. |
retired | Tokens the push service rejected as dead. Engage removes them so the next send is cleaner. |
campaignId | The row this send created in your push history. |
accepted is not the same as delivered. A push service returning 200 means it accepted the message for delivery; whether the handset was reachable is something no push platform reports back.
5. Run an email campaign
Four calls, all on campaigns:write except the last. Each returns an id the next one needs, so keep them as you go.
5.1 Put the audience in a list
A campaign sends to lists or segments. Creating a list needs lists:write:
curl -X POST "$ENGAGE_API/lists" \
-H "Authorization: Bearer $ENGAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "API test list", "description": "Created from the API" }'Add someone to it. The contact is created if that email is not on file yet, so this doubles as an import:
curl -X POST "$ENGAGE_API/lists/LIST_ID/contacts" \
-H "Authorization: Bearer $ENGAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "apikey@example.com",
"first_name": "api",
"last_name": "testing"
}'5.2 Create the campaign
channel and type are numbers, not names. For a normal email campaign send channel: 2 (single channel) and type: 1 (email) — and with channel: 2, `date` and `type` are both required or the request is rejected before it reaches the handler.
curl -X POST "$ENGAGE_API/campaign" \
-H "Authorization: Bearer $ENGAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "API test campaign",
"channel": 2,
"type": 1,
"date": "2026-01-01T00:00:00Z",
"subject": "Hello from the API",
"body": "<h1>Hi {{first_name}}</h1><p>Sent with an API key.</p>",
"sender": "hello@yourdomain.com",
"sender_name": "Your Team",
"reply_to": "hello@yourdomain.com"
}'| Field | Value | Meaning |
|---|---|---|
channel | 1 / 2 | Omni-channel / single-channel |
type | 1 / 2 / 3 / 4 | Email / SMS / IM / Push |
date | ISO 8601 | Required when channel is 2 |
sender | an address on a verified domain | Sending from an unverified domain will fail at send time, not here |
Send the numbers, not the names. "channel": "single" is rejected with channel Input should be 1 or 2, and "type": "email" with type Input should be 1, 2, 3 or 4. The same numbers come back in every campaign response.
5.3 Send it
list_id and segment_ids are arrays, even for one. template_source chooses where the content comes from: "LIST" for a saved template, "DRAFT" for a builder draft. Omit both and the campaign's own body is used.
curl -X POST "$ENGAGE_API/campaign/CAMPAIGN_ID/send" \
-H "Authorization: Bearer $ENGAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"list_id": ["LIST_ID"],
"batch_size": 100,
"delay_between_batches": 1.0
}'{
"status": "success",
"message": "",
"data": {
"success": true,
"message": "Campaign sent",
"total_members": 1,
"emails_sent": 1,
"errors": [],
"started_at": "2026-01-01T00:00:00Z",
"finished_at": "2026-01-01T00:00:04Z"
}
}This sends real email the moment it returns. There is no dry-run on this endpoint — unlike /push/send, which has dryRun. Point it at a list containing only your own address the first time.
batch_size (1–1000) and delay_between_batches (0.1–60 seconds) pace the send. The defaults — 100 per batch, one second apart — are a reasonable starting point; slow them down if your domain is newly warmed.
5.4 Read the results
Needs only campaigns:read:
curl "$ENGAGE_API/campaign/CAMPAIGN_ID/stats" \
-H "Authorization: Bearer $ENGAGE_API_KEY"Related calls on the same key: GET /campaign lists campaigns, GET /campaign/{id}/recipients/activity shows per-recipient opens and clicks, and POST /campaign/{id}/ab-test/start begins an A/B test.
Account-wide reporting is on the same key: GET /api/v1/analytics/overview, /analytics/performance, /analytics/flows/overview and the rest of /api/v1/analytics all need only analytics:read.
6. Start and stop a flow
Everything the Flows screen does is on the API. GET /api/v1/custom-flows lists your flows with the field that matters here — status, one of draft, live or paused. It needs flows:read.
curl "$ENGAGE_API/custom-flows" \
-H "Authorization: Bearer $ENGAGE_API_KEY"Stopping and starting are two separate calls rather than one switch, so a retry cannot turn a flow back on by accident. Both need flows:write.
# stop it — contacts already inside keep their progress
curl -X POST "$ENGAGE_API/custom-flows/FLOW_ID/pause" \
-H "Authorization: Bearer $ENGAGE_API_KEY"
# start it again — they carry on from where they stopped
curl -X POST "$ENGAGE_API/custom-flows/FLOW_ID/resume" \
-H "Authorization: Bearer $ENGAGE_API_KEY"Both are safe to call twice. Pausing a paused flow, or resuming a live one, returns "status": "success" and changes nothing — so a script that cannot see the current state can simply say what it wants.
| Call | What it does |
|---|---|
POST /custom-flows/{id}/pause | Live → paused. Nothing new is enrolled and nothing advances. |
POST /custom-flows/{id}/resume | Paused → live, re-running the same checks the UI runs before activating. |
PATCH /custom-flows/{id}/toggle | Flips whichever state it finds. What the UI's switch calls. |
PATCH /custom-flows/{id} | A body of only {"status": "paused"} or {"status": "live"} makes the same state change, for when you are already sending a PATCH. Send anything else alongside it and the request is refused. |
POST /custom-flows/{id}/duplicate | Copies the flow to a new draft you can edit. |
A resume can be refused, and the message says why: a flow that no longer validates lists what is missing, and an account out of email credits is told to top up. Nothing is half-started.
A flow's content is locked once it has gone live — its steps cannot be rewritten while contacts are partway through them. Pausing does not unlock it. To change a live flow, duplicate it, edit the draft copy, and publish that.
What a key can and cannot do
A key is issued with everything the person who created it can do, and that now covers essentially the whole product: contacts, lists, segments, campaigns, flows, templates, push, landing pages and signup forms, ecommerce, events and analytics. If you can do it on the screen, you can do it with the key. It is never wider than its creator: if your own permissions are reduced later, every key you made narrows with them.
Four things are deliberately withheld from every key, however wide its creator's access:
- Adding or changing team members
- Billing
- Changing account settings
- Creating or listing other API keys
These are the permissions that would let a stolen key entrench itself — invite an accomplice, or mint fresh keys to survive the one you revoke. Nothing an integration legitimately does needs them.
Two smaller things stay out for the same reason: uploading or removing the account's Firebase credentials, and reading invoices. Sending push works on a key; replacing the credentials it sends with does not.
Rate limits
Each key may make 120 requests per minute. The limit is per key, not per server, so scaling your integration across more workers does not buy more requests.
Going over returns HTTP 429 with a message telling you how long to wait. Back off and retry; do not spin.
{
"message": "Too many requests. Try again in 34 seconds.",
"success": false
}Revoking a key
Open API Key, find the key in the table and click the revoke icon in the Actions column. It stops working immediately, and anything using it starts failing on the next call. The row stays in the table, greyed out and marked Revoked.
The record of the key is kept rather than erased, so you can still see what it was called and when it was last used. "Which key could send in March" is a question that outlives the key itself.
Revoke a key the moment you suspect it has leaked, and whenever the integration that used it is retired. Keys do not expire on their own.
Errors
| Status | Meaning | What to do |
|---|---|---|
| 401 | The key is unknown, revoked or expired — or the account it belongs to is deactivated | Check the key was copied in full, then create a new one |
| 429 | Rate limit exceeded | Wait the number of seconds in the message, then retry |
200 with "status": "failure" | Everything else, including a key that lacks the permission for that endpoint | Read message. For a permission failure it reads *You don't have permission to perform this action* |
There is no 403. A permission failure comes back as HTTP 200 with "status": "failure", the same envelope as any other business failure. Code that branches on response.status === 403 will never see it, and code that only checks response.ok will read a refusal as a success.
A 401 is deliberately the same answer for an unknown key, a revoked key and an expired one. Distinguishing them would let anyone with a list of guesses learn which keys exist.
Good practice
- One key per integration. Then revoking a compromised key takes down one thing, not everything.
- Keep it server-side. A key in browser or mobile code is a key you have published.
- Rotate deliberately. Create the new key, deploy it, confirm traffic has moved, then revoke the old one — in that order, so nothing goes dark.
- Watch Last used. A key that has not been used in months is a key nobody will miss when you revoke it.
