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.
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.
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" } }
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.
{
"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..." }
]
}{
"channel": "whatsapp",
"to": "+8801700000000",
"templateId": "tpl_7c31a9d4b2e6",
"variables": { "customerName": "Alex", "orderId": "1042" }
}
# templateId, not a template name.
# The template channel must match "channel".{
"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 goes out on the request itself. The 201 already carries status "sent" and a sentAt timestamp, so a synchronous caller is done.
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.
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.
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.
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/messagesSend on any channel, read one back by id, or page your history with a cursor. Scopes: messages:send, messages:read.
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.
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.
One call returns total, sent, delivered, opened, clicked, bounced and failed per channel over a date range. Counts, per channel and period.
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?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 20queued 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 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.
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
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.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.
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.
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.
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.
X-QuickGrow-Event: message.delivered
X-QuickGrow-Signature: t=1784107455,v1=5257a869e7ec...
{
"id": "evt_9f2c41b8d0a7",
"event": "message.delivered",
"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
}
}
}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"),
);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.
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.
{
"error": {
"code": "invalid_recipient",
"message": "\"to\" must be an E.164 phone number (e.g. +8801700000000) for channel \"sms\"",
"docsUrl": "/support/api-docs#errors"
}
}| Status | When you see it | |
|---|---|---|
| unauthorized | 401 | No key, unknown key, expired key, or a call from an IP the key does not allow. |
| forbidden_scope | 403 | The key is valid but does not carry the scope this route requires. |
| rate_limited | 429 | Over the per-key ceiling for this minute. Read X-RateLimit-Reset and wait. |
| invalid_request | 400 | A field failed validation, or the channel-specific required fields are missing. |
| invalid_recipient | 400 | "to" is not an email address on channel email, or not E.164 on sms/whatsapp. |
| invalid_attachment | 400 | Attachments on a non-email channel, or content that is not decodable base64. |
| attachment_too_large | 400 | The attachments add up to more than 7 MB decoded. |
| template_not_found | 404 | No template with that id on this account, or its channel does not match the send. |
| contact_not_found | 404 | No contact with that id. |
| message_not_found | 404 | No message with that id. |
| webhook_not_found | 404 | No webhook subscription with that id. |
| not_found | 404 | The resource the request referred to no longer exists. |
| conflict | 409 | A record with these values already exists — a duplicate contact, typically. |
| idempotency_conflict | 409 | That Idempotency-Key is still in flight, or was already used on a different endpoint. |
| insufficient_credits | 402 | Not enough balance to send. Top up and retry. |
| send_blocked | 422 | Consent, quiet hours, a frequency cap or the provider refused the send. |
| internal_error | 500 | Something broke on our side. Safe to retry with the same Idempotency-Key. |

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.
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.
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.
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.
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.
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.
Run every channel from one platform. Bring your own SMTP, SMS gateway, and WhatsApp number — or use ours.
See pricing →