Developers

One endpoint. Three channels.

POST /api/v1/messages sends email, SMS or WhatsApp. Thirteen endpoints cover messages, contacts, templates and analytics. Scoped keys, safe retries, signed delivery webhooks, and a published error catalogue you can switch on.

Idempotency-Key on every writeSigned webhooks No SDK to install
POST /api/v1/messages
curl -X POST https://server.quickgrow.ai/api/v1/messages \
  -H "X-API-Key: mk_..." \
  -H "Idempotency-Key: order-1042-shipped" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "sms",
    "to": "+8801700000000",
    "text": "Order 1042 has shipped.",
    "tags": ["order-shipped"],
    "metadata": { "orderId": "1042" }
  }'

# 201 Created
# { "data": { "id": "msg_9f8a3bc2e41d", "status": "queued" } }

One call. Any channel.

The body changes shape with the channel, not the endpoint. Send inline content, or reference a stored template by id and pass its variables. Tags and a metadata object of your own ride along and come back on every status read and every webhook.

email — inline content
{
  "channel": "email",
  "to": "[email protected]",
  "subject": "Your invoice",
  "html": "<p>{tr(locale, "Invoice INV-1042 is attached.")}</p>",
  "fromName": "Rahman Traders",
  "replyTo": "[email protected]",
  "attachments": [
    { "filename": "invoice.pdf",
      "contentType": "application/pdf",
      "content": "JVBERi0xLjQK..." }
  ]
}
whatsapp — stored template
{
  "channel": "whatsapp",
  "to": "+8801700000000",
  "templateId": "tpl_7c31a9d4b2e6",
  "variables": { "customerName": "Alex", "orderId": "1042" }
}

# templateId, not a template name.
# The template channel must match "channel".
GET /api/v1/messages/{id}
{
  "data": {
    "id": "msg_9f8a3bc2e41d",
    "channel": "email",
    "status": "delivered",
    "to": "[email protected]",
    "tags": ["order-confirmation"],
    "metadata": { "orderId": "1042" },
    "createdAt": "2026-08-10T09:14:22.000Z",
    "sentAt": "2026-08-10T09:14:22.000Z",
    "deliveredAt": "2026-08-10T09:14:25.000Z",
    "failedAt": null,
    "failureReason": null,
    "failureCode": null
  }
}

# Every field is camelCase. Every response is
# wrapped in "data". Timestamps are ISO 8601 UTC.
Email

Comes back sent

Email goes out on the request itself. The 201 already carries status "sent" and a sentAt timestamp, so a synchronous caller is done.

SMS · WhatsApp

Comes back queued

SMS and WhatsApp are accepted and handed to a worker. The 201 carries status "queued" — the outcome arrives later, on a webhook or on your next status read.

Email only

Attachments

Up to 10 files and 7 MB decoded in total, base64 in the request body. Attachments on SMS or WhatsApp are refused outright, not silently dropped.

Thirteen endpoints. That is the whole API.

Small enough to read in one sitting, and every one of them is in the published spec. Webhook subscriptions are managed separately — in Settings, or with a key that carries the webhooks:manage scope.

base: https://server.quickgrow.ai
POST   /api/v1/messages
GET    /api/v1/messages/{id}
GET    /api/v1/messages

POST   /api/v1/contacts
POST   /api/v1/contacts/bulk
GET    /api/v1/contacts
GET    /api/v1/contacts/{id}
PATCH  /api/v1/contacts/{id}
DELETE /api/v1/contacts/{id}

POST   /api/v1/templates
GET    /api/v1/templates
GET    /api/v1/templates/{id}

GET    /api/v1/analytics/messages

Messages

Send on any channel, read one back by id, or page your history with a cursor. Scopes: messages:send, messages:read.

Contacts

Create, read, update, delete, and a bulk create of up to 1,000 rows that returns a per-row report of created, duplicate and error. Scopes: contacts:read, contacts:write.

Templates

Create an email or SMS template, then list or fetch across all three channels — including the ones your team built in the dashboard. Any id you see here is a valid templateId.

Analytics

One call returns total, sent, delivered, opened, clicked, bounced and failed per channel over a date range. Counts, per channel and period.

Every message keeps its own record.

Six fields tell you where a message got to and why it stopped: status, sentAt, deliveredAt, failedAt, failureReason and failureCode. They are on the status read, on the list, and inside every webhook payload.

GET /api/v1/messages
GET /api/v1/messages?channel=sms&status=failed&limit=50

{
  "data": [ /* newest first */ ],
  "pagination": {
    "cursor": "msg_7b21c4e9f0a3",
    "hasMore": true
  }
}

# Next page: &cursor=msg_7b21c4e9f0a3
# Filters:   channel, status, createdAfter, createdBefore
# limit:     1-100, default 20
the eight statuses
queued      accepted, not yet handed to a provider
sending     in flight
sent        the provider accepted it
delivered   confirmed by the handset or mail server
opened      an email open, or a WhatsApp read receipt
clicked     an email link click
bounced     the recipient address rejected it
failed      see failureReason / failureCode

# Filterable by channel, status and created date.
# NOT filterable by tag — tags are returned, not queried.

A key that can only do one job.

A key is minted once in Settings → Developer API and shown once. Give it only the scopes the job needs, pin it to the IPs it will call from, and set an expiry. Rotation issues the replacement immediately and leaves the old key working for 24 hours so you can deploy without a gap.

api key · scoped
X-API-Key: mk_1f9c4e8b...
# or: Authorization: Bearer mk_1f9c4e8b...

{
  "name": "Order service (production)",
  "permissions": ["messages:send", "messages:read"],
  "rateLimit": 1000,
  "allowedIps": ["203.0.113.9"],
  "expiresAt": "2027-01-31T00:00:00.000Z"
}

# scopes: messages:send  messages:read
#         contacts:read  contacts:write
#         templates:read templates:write
#         analytics:read webhooks:manage
429 · rate limited
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1784112300

{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit of 1000 requests per minute
                exceeded. Retry after the window resets.",
    "docsUrl": "/support/api-docs#errors"
  }
}

# The three headers are on EVERY keyed response,
# not just the 429. Reset is a Unix timestamp.
Least privilege

Eight scopes

A key can only be granted scopes its creator already holds, and it stops working the moment that person is deactivated. A call outside the key scopes returns 403 forbidden_scope, never a partial result.

Idempotency-Key

Retry without fear

Put a key of your own on any POST. Send it again within 24 hours and the original response replays verbatim with an Idempotent-Replay: true header — no second message. Reuse it on a different endpoint and you get 409.

1,000 req/min

A ceiling you can see

The default is 1,000 requests per minute per key, adjustable per key. Limit, remaining and reset come back on every response, so a client can back off before it hits the wall.

Four events, signed, retried.

Point a URL at your app and QuickGrow posts every state change for messages your key sent. Campaign traffic is deliberately excluded — only messages that came through the API produce events, so your endpoint is never flooded by a bulk send.

POST https://your-app.example/hooks
X-QuickGrow-Event: message.delivered
X-QuickGrow-Signature: t=1784107455,v1=5257a869e7ec...

{
  "id": "evt_9f2c41b8d0a7",
  "event": &quot;message.delivered&quot;,
  "createdAt": "2026-08-10T09:14:25.000Z",
  "data": {
    "message": {
      "id": "msg_9f8a3bc2e41d",
      "channel": "email",
      "status": "delivered",
      "to": "[email protected]",
      "tags": ["order-confirmation"],
      "metadata": { "orderId": "1042" },
      "createdAt": "2026-08-10T09:14:22.000Z",
      "sentAt": "2026-08-10T09:14:22.000Z",
      "deliveredAt": "2026-08-10T09:14:25.000Z",
      "failedAt": null,
      "failureReason": null,
      "failureCode": null
    }
  }
}
verify · node
import { createHmac, timingSafeEqual } from "node:crypto";

const header = req.get("X-QuickGrow-Signature");
const { t, v1 } = Object.fromEntries(
  header.split(",").map((p) => p.split("=")),
);

// Reject anything stale. 300s is a sane default.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return;

const expected = createHmac("sha256", secret)
  .update(t + "." + req.rawBody)
  .digest("hex");

// Compare in constant time. Sign the RAW body —
// not the object your JSON parser handed you.
timingSafeEqual(
  Buffer.from(expected, "hex"),
  Buffer.from(v1, "hex"),
);

The four events

message.sentmessage.deliveredmessage.failedmessage.bounced

Each delivery is attempted immediately, then after 5 seconds, 1 minute, 5 minutes and 30 minutes — five attempts, then it stops. Every attempt is logged with its status code and response, and you can fire a test delivery or roll the signing secret at any time.

⚠ Retries are best effort, not a guarantee. Treat GET /messages as the source of truth and reconcile.

One envelope. A code you can branch on.

Every failure on every endpoint comes back in the same shape, with a stable machine-readable code. Codes are only ever added, never renamed or removed, so a switch statement you write today keeps working.

400 · invalid_recipient
{
  "error": {
    "code": "invalid_recipient",
    "message": "\"to\" must be an E.164 phone number (e.g. +8801700000000) for channel \"sms\"",
    "docsUrl": "/support/api-docs#errors"
  }
}
The full catalogue. Seventeen codes, and this is all of them.
StatusWhen you see it
unauthorized401No key, unknown key, expired key, or a call from an IP the key does not allow.
forbidden_scope403The key is valid but does not carry the scope this route requires.
rate_limited429Over the per-key ceiling for this minute. Read X-RateLimit-Reset and wait.
invalid_request400A field failed validation, or the channel-specific required fields are missing.
invalid_recipient400"to" is not an email address on channel email, or not E.164 on sms/whatsapp.
invalid_attachment400Attachments on a non-email channel, or content that is not decodable base64.
attachment_too_large400The attachments add up to more than 7 MB decoded.
template_not_found404No template with that id on this account, or its channel does not match the send.
contact_not_found404No contact with that id.
message_not_found404No message with that id.
webhook_not_found404No webhook subscription with that id.
not_found404The resource the request referred to no longer exists.
conflict409A record with these values already exists — a duplicate contact, typically.
idempotency_conflict409That Idempotency-Key is still in flight, or was already used on a different endpoint.
insufficient_credits402Not enough balance to send. Top up and retry.
send_blocked422Consent, quiet hours, a frequency cap or the provider refused the send.
internal_error500Something broke on our side. Safe to retry with the same Idempotency-Key.
The QuickGrow API reference: an endpoint navigator on the left beside the request and response detail for an endpoint.
Reference

The docs are part of the product.

The API reference lives inside QuickGrow, next to the key you are about to use: every endpoint, every field, request and response samples in cURL, Node, Python and PHP, the error catalogue and a webhook-verification walkthrough. The same surface is published as an OpenAPI document you can import into Postman or a generator.

  • Browse it at /support/api-docs — no login needed to read it
  • Machine-readable spec at /api/public-docs/openapi.json
  • There is no package to install: any HTTP client works

What this API will not do.

The things worth knowing before you write the integration, not after. None of these are on a roadmap page — they are how the API behaves today.

Rules that still apply to API sends

  • Quiet hours are enforced on SMS and WhatsApp, transactional sends included. In Bangladesh that is 21:00–08:00 Asia/Dhaka; the window is set per country.
  • A send blocked by quiet hours returns 422 send_blocked. It is refused, not held and sent later — so never schedule a one-time code or a night-time alert on those channels.
  • Email has no quiet-hours check. If it has to go out at 2am, send it as email.

Boundaries

  • No published SDK. There is no npm or pip package to install — you call the REST API directly.
  • Attachments are email only: 10 files, 7 MB decoded. SMS and WhatsApp attachments are rejected.
  • No cross-channel fallback. A WhatsApp send that fails does not go out as SMS — it fails, with a reason and a code you can read.

Shapes to plan for

  • Email is synchronous and returns "sent"; SMS and WhatsApp return "queued" and settle later.
  • Messages page by cursor. Contacts page by page number. They are genuinely different — do not share pagination code.
  • Messages filter by channel, status and created date. Not by tag. Contacts do filter by tag.
Questions

Answers before you integrate.

Which SDK should I install?

None — there is no published package. Every endpoint is plain JSON over HTTPS with a single X-API-Key header, so whatever HTTP client your language already has is enough. The reference ships runnable samples in cURL, Node, Python and PHP, and the OpenAPI document at /api/public-docs/openapi.json will generate a client if you want one.

What happens if my request times out and I retry?

Put your own Idempotency-Key header on the POST. If the first attempt actually landed, the retry replays the stored response verbatim — same body, same status, plus an Idempotent-Replay: true header — and no second message goes out. Keys are held for 24 hours. If the first attempt failed, the key is released so the retry runs for real. Reusing one key across two different endpoints returns 409 idempotency_conflict.

Will a one-time passcode go out at 11pm?

Not on SMS or WhatsApp. Quiet hours are enforced on the send path for every message that resolves to a contact, and the public API always resolves one — transactional sends included. In Bangladesh the window is 21:00–08:00 Asia/Dhaka, and the call returns 422 send_blocked rather than queueing for morning. Email is not gated, so time-critical codes are safest on email or inside daytime hours.

curl · fetch · requests · guzzle scoped keys · IP allowlist · expiry quiet hours enforced, per country

Read the reference, then send one.

The API reference is public and the spec is machine-readable. Create an account, mint a scoped key in Settings → Developer API, and your first message is one POST away.