On this page
QuickGrow AI API
A messaging API for whatever you are building — a storefront, a SaaS product, an ERP, a booking system, an internal tool. Send transactional email, SMS and WhatsApp from one endpoint, keep contacts in sync, and get delivery events pushed back to you as they happen.
Thirteen endpoints, JSON in and JSON out, one API key in a header. Every response is wrapped in a data object; list endpoints add a pagination object beside it; errors return an error object with a stable code. Field names are camelCase throughout, and every timestamp is ISO 8601 in UTC.
Base URL
https://server.quickgrow.ai/api/v1msg_, ct_ or tpl_ prefix on anything you will store. The only prefixed identifier in the whole API is the evt_ on a webhook event. Do not validate ids by their shape.Getting started
The three steps below get a message out the door. The path after that — where most integrations end up — is sketched underneath them.
Create an API key
Go to Settings → Developer API and generate a key with the messages:send and messages:read scopes. A key is mk_ + 64 hex characters (67 characters in total) and is shown once, at creation. There is no separate test key — see testing safely before you point this at anything real.
Send your first message
Start on email. It sends while your request is still open, so the response already tells you whether it left the platform.
curl -X POST "https://server.quickgrow.ai/api/v1/messages" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"to": "[email protected]",
"subject": "Welcome aboard!",
"html": "<h1>Welcome!</h1><p>Your account is ready to use.</p>"
}'{
"data": {
"id": "9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846",
"channel": "email",
"status": "sent",
"to": "[email protected]",
"tags": [],
"metadata": {},
"createdAt": "2026-07-15T09:24:11.000Z",
"sentAt": "2026-07-15T09:24:12.000Z",
"deliveredAt": null,
"failedAt": null,
"failureReason": null,
"failureCode": null
}
}Check the delivery status
Read GET /messages/{id} back with the id from the previous response. Email starts at sent and moves to delivered when the receiving server confirms it; SMS and WhatsApp start at queued. Once that round trip works, stop polling and subscribe to webhooks instead.
curl "https://server.quickgrow.ai/api/v1/messages/9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846" \
-H "X-API-Key: mk_your_api_key"Where to go from there
- 1Decide which channel you actually need. Email is the one that is ungated: it sends the moment you call, it takes attachments, and nothing holds it back for the time of day. SMS and WhatsApp go through a compliance gate first, so treat them as best-effort and always read the response status.
- 2Create a key with only the scopes you will use. A key that can send messages does not need to be able to delete contacts. You can hold several keys at once, so give each service its own and revoke them independently.
- 3Send one message to yourself and watch it land. The response comes back with an id. Read that id back through GET /messages/{id} until the status settles — that round trip tells you your auth, your envelope handling and your error handling all work.
- 4Move the content into a template. Once the copy stops changing, create a template and send templateId with a variables object instead of a body. Marketing can then edit the wording in the dashboard without a deploy.
- 5Replace polling with a webhook. Register an endpoint, subscribe to the delivery events you care about, verify the signature, and stop polling. Your metadata comes back on every event, so you can match it to your own records without a lookup table.
Endpoint index
The public API is 13 endpoints — three for messages, six for contacts, three for templates and one for analytics. That is the whole surface, and it is exactly what the OpenAPI document describes. Webhook subscriptions are managed with the same key but sit outside that specification, and are documented separately below.
| Group | Endpoint | What it does |
|---|---|---|
| Messages | POST/messages | Send one email, SMS or WhatsApp message. |
| Messages | GET/messages/{id} | Read one message and its delivery timestamps. |
| Messages | GET/messages | List messages, newest first, with a cursor. |
| Contacts | POST/contacts | Create one contact. |
| Contacts | POST/contacts/bulk | Create up to 1,000 contacts, with a per-row result. |
| Contacts | GET/contacts | List contacts with search, filters and page numbers. |
| Contacts | GET/contacts/{id} | Read one contact. |
| Contacts | PATCH/contacts/{id} | Change some fields on a contact. |
| Contacts | DELETE/contacts/{id} | Remove a contact from your account. |
| Templates | POST/templates | Create an email or SMS template. |
| Templates | GET/templates | List every template, all channels. |
| Templates | GET/templates/{id} | Read one template and the variables it expects. |
| Analytics | GET/analytics/messages | Delivery counters per channel over a date range. |
Two things this API deliberately does not do: there are no published client libraries to install, and an API key cannot start an automation. Everything an integration can do is on the list above.
Testing safely
There is no sandbox and no test key
Keys are not split into test and live. Every call you make hits your real account: real contacts are created, real messages go out, and your credits are really spent. Point your first calls at an address and a phone number you own.
Start on email
Email sends synchronously and is not subject to consent, quiet hours or a per-contact cap, so it is the one channel that will always behave the same at 3pm and at 3am. Get your request, envelope and error handling right there before you add SMS or WhatsApp.
Use an Idempotency-Key from the first request
Retrofitting it after a timeout has already double-charged you is the wrong order. Derive the key from the thing you are sending about — order-1042-confirmation — not from a random UUID per attempt, or retries will not match.
Prove your webhook receiver before you point it at production
Create the subscription, call POST /webhooks/{id}/test to prove the URL is reachable, then send one real message to yourself and check the signature on the event that arrives. The test event uses only the legacy signature header, so it cannot exercise your X-QuickGrow-Signature check on its own.
Watch the rate-limit headers, not the 429
Every keyed response carries X-RateLimit-Remaining. Throttle on that while you are load-testing rather than discovering the ceiling by hitting it.
Give each environment its own key
Scope the staging key down to what staging actually needs, and you limit the blast radius of a leaked or misconfigured deploy. A key can be revoked on its own without touching the others.
Authentication
Every request must carry an API key, created in Settings → Developer API. Send it in the X-API-Key header, or in Authorization with either the ApiKey or the Bearer scheme. All three are equivalent.
A key is mk_ + 64 hex characters (67 characters in total). There is no test-versus-live split and no separate secret to pair it with — the key is the whole credential.
# Primary — send your key in the X-API-Key header
X-API-Key: mk_2f8a91c7e28d4b6a0c5e19f7b4d63a08c71e5f92d0a4b8c63e17f9d25a08b4c6
# Equivalent — the Authorization header, with either scheme
Authorization: ApiKey mk_2f8a91c7e28d4b6a0c5e19f7b4d63a08c71e5f92d0a4b8c63e17f9d25a08b4c6
Authorization: Bearer mk_2f8a91c7e28d4b6a0c5e19f7b4d63a08c71e5f92d0a4b8c63e17f9d25a08b4c6Scopes
Each key is granted a set of scopes when you create it. Calling an endpoint the key was not granted returns 403 forbidden_scope. Grant each integration only what it needs — a service that sends order confirmations has no reason to be able to delete contacts or export them.
| Scope | Grants |
|---|---|
| messages:send | Send messages (POST /messages). |
| messages:read | Read message status and message lists. |
| contacts:read | List and retrieve contacts. |
| contacts:write | Create, update and delete contacts, including the bulk endpoint. Deleting uses this scope, not a separate delete scope. |
| templates:read | List and retrieve message templates on every channel. |
| templates:write | Create message templates (POST /templates). |
| analytics:read | Read aggregate delivery analytics. |
| webhooks:manage | List, read, create, update, delete, test and re-key webhook subscriptions. |
Errors
The API uses ordinary HTTP status codes and one consistent JSON envelope. A success wraps the result in data; a list adds a pagination object beside it; a failure returns an error object instead of data.
Branch on error.code, never on error.message. The codes are a published contract and are only ever added to; the messages are written for a human reading a log and can be reworded at any time.
Success envelope
{
"data": {
"id": "9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846",
"status": "sent"
}
}List envelope — cursor
{
"data": [ { "...": "..." }, { "...": "..." } ],
"pagination": { "cursor": "7d2c4fa1-b8e9-4c30-a5f2-91b60d3e4478", "hasMore": true }
}List envelope — pages
{
"data": [ { "...": "..." } ],
"pagination": { "page": 1, "limit": 20, "total": 184, "totalPages": 10 }
}?cursor= until hasMore is false. Contacts use page numbers. Webhook delivery logs are a third case: their counters sit at the top level, not inside pagination.Error envelope
{
"error": {
"code": "invalid_recipient",
"message": "\"to\" must be a valid email address for channel \"email\"",
"docsUrl": "/support/api-docs#errors"
}
}docsUrl is a path on this site, not an absolute URL — resolve it against https://quickgrow.ai if you are turning it into a link. The webhook management endpoints predate this envelope and still return the platform's older error shape (statusCode, error, message, timestamp, path), so a client that parses errors strictly should handle both.
Error codes
This is the complete catalogue. Anything you get back that is not on this list is a bug on our side — please report it.
| Code | HTTP status | Description |
|---|---|---|
| invalid_request | 400 | The body or query string failed validation. message names the parameter that is wrong; several validation failures are joined with "; ". |
| invalid_recipient | 400 | "to" is not a valid email address (channel "email") or a 7–15 digit E.164 phone number (channels "sms" and "whatsapp"). |
| invalid_attachment | 400 | An attachment was sent on a channel other than "email", or its content did not decode as base64. |
| attachment_too_large | 400 | The attachments decode to more than 7 MB in total. The message says how large they actually were. |
| unauthorized | 401 | The API key is missing, malformed, or has been revoked. |
| insufficient_credits | 402 | Your credit balance for that channel does not cover this send, so nothing was sent. Buy more credits and retry — the send is not queued and will not go out on its own. |
| forbidden_scope | 403 | The key was not granted the scope this endpoint requires. |
| template_not_found | 404 | No template with the given templateId exists on your account, or it exists on a different channel than the one you are sending on. |
| contact_not_found | 404 | No contact with this id exists on your account. |
| message_not_found | 404 | No message with this id exists on your account. |
| webhook_not_found | 404 | No webhook subscription with this id exists on your account. |
| not_found | 404 | The requested resource does not exist. |
| conflict | 409 | A record with these values already exists — most often a contact whose email or phone you already hold. |
| idempotency_conflict | 409 | The same Idempotency-Key is either still in flight, or was already used against a different endpoint. Retry after a short delay, or pick a new key. |
| send_blocked | 422 | The send was refused before it left the platform: no active consent, quiet hours, a per-contact cap, the WhatsApp 24-hour window, a spam scan, or a frozen account. message says which. |
| rate_limited | 429 | Too many requests. Check the X-RateLimit-* headers and retry after the reset timestamp. |
| internal_error | 500 | Something went wrong on our side. Safe to retry — reuse your Idempotency-Key to avoid duplicates. |
429 and 5xx; those are transient. Do not retry 400, 401, 403, 404 or 422 — the same request will fail the same way. When you do retry a write, reuse the original Idempotency-Key so a request that actually succeeded before timing out is not performed twice.Rate limits
Requests are counted per API key — 1,000 per minute unless a different limit was set on the key. The window is a fixed minute bucket rather than a sliding one, so a burst that straddles a boundary can pass where the same burst a second later would not. Every keyed response carries headers describing where you stand, so a client can throttle on those instead of discovering the ceiling by hitting it.
| Header | Description |
|---|---|
| X-RateLimit-Limit | Requests allowed per minute for this key. 1000 unless a different limit was set on the key. |
| X-RateLimit-Remaining | Requests left in the current window. Floors at 0 rather than going negative. |
| X-RateLimit-Reset | Unix timestamp (seconds) at which the current window resets. Windows are fixed minute buckets, not a sliding window, so the reset can be less than 60 seconds away. |
Over the limit, the API returns 429 with code rate_limited. Wait until the X-RateLimit-Reset timestamp — Unix seconds — and retry, with exponential backoff and jitter so a fleet of workers does not all wake at the same instant. The limit is on requests, not on messages, so batching contacts through POST /contacts/bulk costs one request rather than a thousand.
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"
}
}Idempotency
Network timeouts make it hard to know whether a write reached us. To retry safely, send an Idempotency-Key header (any unique string, max 255 characters) on POST /messages, POST /contacts, POST /contacts/bulk and POST /templates. A good key is derived from the action itself — e.g. order-1042-confirmation.
- Repeating a request with the same key within 24 hours replays the original response instead of performing the action again — same status, same body, plus the header
Idempotent-Replay: true. After 24 hours the key is released and can be used again. - If a duplicate arrives while the first request is still in flight, the API returns
409 idempotency_conflict. Retry after a short delay. - Reusing a key against a different endpoint also returns
409 idempotency_conflict. Keys are scoped to the endpoint they were first used on, so give each operation its own. - If the request fails, the key is released rather than held — a retry after an error is a fresh attempt, not a replayed failure.
- A key longer than 255 characters is rejected with
400 invalid_request.
order-1042-confirmation stays stable across every retry of the same logical action.curl -X POST "https://server.quickgrow.ai/api/v1/messages" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-confirmation" \
-d '{
"channel": "email",
"to": "[email protected]",
"subject": "Your order #1042 is confirmed",
"html": "<p>Order #1042 has been confirmed.</p>"
}'Messages
Messages are the core of the API. One endpoint sends over email, SMS or WhatsApp, and the same message object tracks it from creation through to a final state — with the timestamp for each stage it reached, and a reason and code when it failed.
Email behaves differently from the other two, and the difference matters when you design a flow. Email is sent inside your request and comes back sent, so the response already tells you it left. SMS and WhatsApp are queued and come back queued: a 201 means accepted, not delivered. Watch a webhook or read the message back to learn what happened next.
| Status | Filterable | Meaning |
|---|---|---|
| queued | Yes | Accepted and waiting for a worker. The starting status for SMS and WhatsApp. |
| sending | Yes | A worker has picked it up and is handing it to the provider. |
| sent | Yes | Accepted by the provider. The starting status for email, which sends synchronously. |
| delivered | Yes | Delivery confirmed by the recipient server or handset. |
| opened | Yes | The recipient opened the message. Email only, and only when open tracking fires. |
| clicked | Yes | The recipient clicked a tracked link. Email only. |
| bounced | Yes | The recipient address rejected the message. |
| failed | Yes | Sending failed terminally — read failureReason and failureCode. |
| read | No | WhatsApp read receipt. Can be returned, but ?status=read is rejected by the list filter. |
| rejected | No | Refused by the provider. Counts as failed in analytics. Not accepted by the list filter. |
| unsubscribed | No | The recipient opted out. Not accepted by the list filter. |
| complained | No | The recipient marked it as spam. Not accepted by the list filter. |
Send a message
/messagesmessages:sendSends one transactional message over email, SMS or WhatsApp. Provide inline content (subject/html/text) or reference a stored template with templateId and fill its {{variable}} placeholders via variables. Email is sent synchronously and comes back status "sent"; SMS and WhatsApp are enqueued and come back "queued".
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
| channel | string | Yes | Delivery channel: "email", "sms" or "whatsapp". |
| to | string | Yes | An email address for channel "email"; a 7–15 digit E.164 phone number for "sms" and "whatsapp" (e.g. +8801700000000). Max 320 characters. |
| subject | string | Email only | Subject line, max 500 characters. Required for email unless you pass templateId. Ignored on SMS and WhatsApp. |
| html | string | Email only | HTML body. Email needs html or text (or a templateId). |
| text | string | SMS / WhatsApp | The message body for SMS and WhatsApp — required there unless you pass templateId. On email it is the plain-text alternative. |
| from | string | No | Sender address (email only). On managed sending an address whose domain is not a verified sender domain is silently replaced with the platform address — the send still succeeds. Verify your domain first if the From matters. |
| fromName | string | No | Sender display name (email only), max 200 characters. Line breaks and double quotes are rejected. The address itself still has to be one you may send as. |
| replyTo | string | No | Reply-to address (email only). |
| templateId | string | No | The id of a stored template to render instead of inline content. It must belong to the same channel — an email template id on an SMS send returns 404 template_not_found. This is an id, not a template name. |
| variables | object | No | String values substituted into {{variable}} placeholders. Whitespace inside the braces is tolerated. A placeholder with no matching key is left in the output as-is rather than blanked. |
| tags | string[] | No | Labels stored on the message and returned on reads. They are not a filter on GET /messages. |
| metadata | object | No | Your own reference data, returned verbatim on reads and webhooks. |
| attachments | object[] | No | Email only — SMS and WhatsApp attachments are rejected with 400 invalid_attachment. At most 10 files and 7 MB decoded across all of them. |
| attachments[].filename | string | Yes | Max 255 characters. Path separators and control characters are rejected. |
| attachments[].content | string | Yes | The file, base64-encoded. |
| attachments[].contentType | string | No | MIME type, e.g. application/pdf. |
| attachments[].cid | string | No | Content-ID, so the HTML body can reference the file inline. |
Headers
| Field | Type | Required | Description |
|---|---|---|---|
| Idempotency-Key | string | No | Up to 255 characters. Protects against duplicate sends — see Idempotency. |
Example request
curl -X POST "https://server.quickgrow.ai/api/v1/messages" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"to": "[email protected]",
"subject": "Your order #1042 is confirmed",
"html": "<h1>Thanks for your order!</h1><p>Order #1042 is confirmed and being prepared.</p>",
"from": "[email protected]",
"replyTo": "[email protected]",
"tags": [
"order-confirmation"
],
"metadata": {
"orderId": "1042"
}
}'Example response
{
"data": {
"id": "9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846",
"channel": "email",
"status": "sent",
"to": "[email protected]",
"tags": ["order-confirmation"],
"metadata": { "orderId": "1042" },
"createdAt": "2026-07-15T09:24:11.000Z",
"sentAt": "2026-07-15T09:24:12.000Z",
"deliveredAt": null,
"failedAt": null,
"failureReason": null,
"failureCode": null
}
}Send an SMS from a stored template (e.g. a one-time code)
curl -X POST "https://server.quickgrow.ai/api/v1/messages" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "sms",
"to": "+8801700000000",
"templateId": "7c31a9d4-b2e6-4f05-8c93-1d6e05a7b284",
"variables": {
"code": "482913"
},
"metadata": {
"loginAttemptId": "la_20718"
}
}'{
"data": {
"id": "4c7e19d0-a2b5-4f81-b06c-3d9a25e7f140",
"channel": "sms",
"status": "queued",
"to": "+8801700000000",
"tags": [],
"metadata": { "loginAttemptId": "la_20718" },
"createdAt": "2026-07-15T09:31:02.000Z",
"sentAt": null,
"deliveredAt": null,
"failedAt": null,
"failureReason": null,
"failureCode": null
}
}Attach a file (email only — 10 files, 7 MB decoded)
curl -X POST "https://server.quickgrow.ai/api/v1/messages" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"to": "[email protected]",
"subject": "Invoice INV-2107",
"html": "<p>Your invoice is attached.</p>",
"attachments": [
{
"filename": "invoice-2107.pdf",
"content": "JVBERi0xLjQKJcfsj6IKNSAwIG9iago8PC9MZW5ndGg...",
"contentType": "application/pdf"
}
]
}'Retrieve a message
/messages/{id}messages:readReturns the current delivery state of one message: its status, the timestamp for each stage it has reached, and the failure reason and code when it failed.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The id returned by POST /messages. |
Response fields
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | — | UUID. Not prefixed — do not pattern-match on "msg_". |
| channel | string | — | Lower-case: "email", "sms" or "whatsapp". |
| status | string | — | Lower-case delivery status. See the status table. |
| to | string | null | — | The recipient you passed in. Null on messages that were not sent through this API. |
| tags | string[] | — | The tags you sent. Empty array if you sent none. |
| metadata | object | — | Your own metadata object, returned verbatim. Empty object if you sent none. |
| createdAt | string | — | ISO 8601, UTC. When the message row was created. |
| sentAt | string | null | — | When it left the platform. Null while queued. |
| deliveredAt | string | null | — | When the receiving server or handset confirmed delivery. |
| failedAt | string | null | — | When it failed terminally. |
| failureReason | string | null | — | Human-readable failure text from the provider or the compliance gate. |
| failureCode | string | null | — | Provider or platform failure code, when one was returned. |
Example request
curl "https://server.quickgrow.ai/api/v1/messages/9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": {
"id": "9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846",
"channel": "email",
"status": "delivered",
"to": "[email protected]",
"tags": ["order-confirmation"],
"metadata": { "orderId": "1042" },
"createdAt": "2026-07-15T09:24:11.000Z",
"sentAt": "2026-07-15T09:24:12.000Z",
"deliveredAt": "2026-07-15T09:24:15.000Z",
"failedAt": null,
"failureReason": null,
"failureCode": null
}
}List messages
/messagesmessages:readLists messages on your account, newest first, with cursor pagination. Pass pagination.cursor from the previous page as ?cursor= to fetch the next one; the cursor is the id of the last row on the page, and it is null once hasMore is false.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| channel | string | No | One of email, sms, whatsapp. Anything else is 400. |
| status | string | No | One of queued, sending, sent, delivered, opened, clicked, bounced, failed. The other statuses a message can hold — read, rejected, unsubscribed, complained — are not accepted here. |
| createdAfter | string | No | ISO 8601. Messages created at or after this moment. |
| createdBefore | string | No | ISO 8601. Messages created at or before this moment. |
| limit | integer | No | Page size, 1–100. Defaults to 20. |
| cursor | string | No | The pagination.cursor value from the previous page. |
Example request
curl "https://server.quickgrow.ai/api/v1/messages?channel=email&status=delivered&limit=20" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": [
{
"id": "9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846",
"channel": "email",
"status": "delivered",
"to": "[email protected]",
"tags": ["order-confirmation"],
"metadata": { "orderId": "1042" },
"createdAt": "2026-07-15T09:24:11.000Z",
"sentAt": "2026-07-15T09:24:12.000Z",
"deliveredAt": "2026-07-15T09:24:15.000Z",
"failedAt": null,
"failureReason": null,
"failureCode": null
},
{
"id": "7d2c4fa1-b8e9-4c30-a5f2-91b60d3e4478",
"channel": "email",
"status": "delivered",
"to": "[email protected]",
"tags": ["invoice"],
"metadata": { "invoiceId": "INV-2107" },
"createdAt": "2026-07-15T08:02:44.000Z",
"sentAt": "2026-07-15T08:02:45.000Z",
"deliveredAt": "2026-07-15T08:02:49.000Z",
"failedAt": null,
"failureReason": null,
"failureCode": null
}
],
"pagination": {
"cursor": "7d2c4fa1-b8e9-4c30-a5f2-91b60d3e4478",
"hasMore": true
}
}Contacts
Contacts are the people you message. Every send resolves one: if the recipient is new, a contact is created for them automatically with source: "api". You can also create them yourself, one at a time or up to a thousand per request, and store your own attributes on each in customFields.
Two things to know before you build against these. Contacts count against your plan's limit, so a create can fail with a 403 that is about capacity rather than permissions. And nothing here starts an automation — creating a contact, adding a tag, or changing a field through the API fires no journey.
| Status | Meaning |
|---|---|
| active | The default for every contact you create. The only status that campaigns will target. |
| unsubscribed | The contact opted out. Set by a one-click email unsubscribe, or by hand in the dashboard. |
| bounced | Mail to this address hard-bounced. |
| complained | The contact reported a message as spam. |
| quarantined | Held back by abuse prevention pending review. |
Create a contact
/contactscontacts:writeCreates a contact on your account. At least one of email or phone is required — an email-only or phone-only contact is valid. New contacts always start with status "active".
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
| string | One of email / phone | Contact email address. Must be unique on your account — a repeat returns 409 conflict. | |
| phone | string | One of email / phone | Phone number in E.164 format. Also unique on your account. |
| firstName | string | No | First name, max 100 characters. |
| lastName | string | No | Last name, max 100 characters. |
| tags | string[] | No | Labels you can filter and segment on. Adding a tag here does not start an automation. |
| source | string | No | Free text recording where the contact came from, e.g. "checkout". There is no default — omit it and source comes back null. Contacts auto-created by POST /messages get "api". |
| customFields | object | No | Your own key–value data, up to 10 KB when serialised; a larger object is dropped rather than rejected. Segments cannot read these fields. |
Headers
| Field | Type | Required | Description |
|---|---|---|---|
| Idempotency-Key | string | No | Up to 255 characters — see Idempotency. |
Example request
curl -X POST "https://server.quickgrow.ai/api/v1/contacts" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"phone": "+8801700000000",
"firstName": "Ayesha",
"lastName": "Rahman",
"tags": [
"customer",
"priority"
],
"source": "checkout",
"customFields": {
"plan": "pro",
"companySize": 25
}
}'Example response
{
"data": {
"id": "5d21e7ab-90cf-4a13-8e26-b7c04f591d38",
"email": "[email protected]",
"phone": "+8801700000000",
"firstName": "Ayesha",
"lastName": "Rahman",
"tags": ["customer", "priority"],
"source": "checkout",
"status": "active",
"customFields": { "plan": "pro", "companySize": 25 },
"createdAt": "2026-07-15T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:11.000Z"
}
}Bulk create contacts
/contacts/bulkcontacts:writeImports up to 1,000 contacts in one request. Each item takes the same fields as POST /contacts. Returns 200 with a per-row outcome in input order — this endpoint never returns 4xx because one row was bad.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
| contacts | object[] | Yes | Between 1 and 1,000 contact objects, each with the same fields as POST /contacts. |
Headers
| Field | Type | Required | Description |
|---|---|---|---|
| Idempotency-Key | string | No | Up to 255 characters — see Idempotency. |
Example request
curl -X POST "https://server.quickgrow.ai/api/v1/contacts/bulk" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{
"email": "[email protected]",
"firstName": "Ayesha",
"tags": [
"customer"
]
},
{
"phone": "+8801700000000",
"firstName": "Jordan",
"source": "import"
},
{
"firstName": "Nabila"
}
]
}'Example response
{
"data": {
"total": 3,
"created": 1,
"duplicates": 1,
"errors": 1,
"results": [
{
"index": 0,
"status": "duplicate",
"error": "Contact with this email already exists"
},
{ "index": 1, "status": "created", "id": "8b44f1a2-d3e0-4b95-9f17-6c2a0e83b451" },
{
"index": 2,
"status": "error",
"error": "Either email or phone is required"
}
]
}
}List contacts
/contactscontacts:readLists contacts with page numbers, search and filters. Note the pagination shape here is page-based, unlike GET /messages which is cursor-based.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| page | integer | No | Page number, from 1. Defaults to 1. |
| limit | integer | No | Page size, 1–100. Defaults to 20. |
| search | string | No | Matches against name, email and phone. |
| status | string | No | One of ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED, QUARANTINED. Upper case here, even though the response returns the status lower-cased. |
| tags | string | No | Comma-separated tags. Matches a contact carrying any of them. |
| source | string | No | Exact match on source, e.g. "api" or "checkout". |
| createdAfter | string | No | ISO 8601 — contacts created after this moment. |
| createdBefore | string | No | ISO 8601 — contacts created before this moment. |
| minEngagementScore | integer | No | Only contacts at or above this engagement score. |
| sortBy | string | No | Column to sort on. Defaults to createdAt. |
| sortOrder | string | No | "asc" or "desc". Defaults to desc. |
Example request
curl "https://server.quickgrow.ai/api/v1/contacts?search=rahman&tags=customer,priority&page=1&limit=20" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": [
{
"id": "5d21e7ab-90cf-4a13-8e26-b7c04f591d38",
"email": "[email protected]",
"phone": "+8801700000000",
"firstName": "Ayesha",
"lastName": "Rahman",
"tags": ["customer", "priority"],
"source": "checkout",
"status": "active",
"customFields": { "plan": "pro" },
"createdAt": "2026-07-15T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:11.000Z"
}
],
"pagination": { "page": 1, "limit": 20, "total": 184, "totalPages": 10 }
}Retrieve a contact
/contacts/{id}contacts:readReturns a single contact by ID.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Contact ID. |
Example request
curl "https://server.quickgrow.ai/api/v1/contacts/5d21e7ab-90cf-4a13-8e26-b7c04f591d38" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": {
"id": "5d21e7ab-90cf-4a13-8e26-b7c04f591d38",
"email": "[email protected]",
"phone": "+8801700000000",
"firstName": "Ayesha",
"lastName": "Rahman",
"tags": ["customer", "priority"],
"source": "checkout",
"status": "active",
"customFields": { "plan": "pro", "companySize": 25 },
"createdAt": "2026-07-15T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:11.000Z"
}
}Update a contact
/contacts/{id}contacts:writePartially updates a contact. Accepts the same fields as POST /contacts; only the fields you send are changed.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Contact id. |
Example request
curl -X PATCH "https://server.quickgrow.ai/api/v1/contacts/5d21e7ab-90cf-4a13-8e26-b7c04f591d38" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Ayesha",
"tags": [
"customer",
"vip"
],
"customFields": {
"plan": "enterprise"
}
}'Example response
{
"data": {
"id": "5d21e7ab-90cf-4a13-8e26-b7c04f591d38",
"email": "[email protected]",
"phone": "+8801700000000",
"firstName": "Ayesha",
"lastName": "Rahman",
"tags": ["customer", "priority"],
"source": "checkout",
"status": "active",
"customFields": { "plan": "pro", "companySize": 25 },
"createdAt": "2026-07-15T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:11.000Z"
}
}Delete a contact
/contacts/{id}contacts:writeRemoves a contact from your account. The record is retired rather than erased: it stops appearing in list and retrieve results and can no longer be messaged, but the messages already sent to it stay in your history.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Contact id. |
Example request
curl -X DELETE "https://server.quickgrow.ai/api/v1/contacts/5d21e7ab-90cf-4a13-8e26-b7c04f591d38" \
-H "X-API-Key: mk_your_api_key"Returns 204 No Content with an empty body.
Templates
Templates are reusable message bodies with {{variable}} placeholders that are filled in at send time. Create email and SMS templates here, or manage them in the QuickGrow AI dashboard, then send one by passing its templateId and a variables object to POST /messages. The template's channel must match the message channel.
You reference a template by id, never by name — there is no template name parameter anywhere in this API. A placeholder with no matching value is left in the text as {{name}} rather than blanked, which is deliberate: a visible gap in a test send is easier to catch than a silent one.
WhatsApp templates are read-only here. QuickGrow does not submit templates to Meta on your behalf — you approve them in your own WhatsApp Business account and they are synced back, which is why they appear in these lists but cannot be created through this endpoint.
Create a template
/templatestemplates:writeCreates an email or SMS template. Put {{variable}} placeholders in the subject and body and list their names in variables; the values are supplied per message via POST /messages.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
| channel | string | Yes | "email" or "sms". "whatsapp" is rejected. |
| name | string | Yes | Template name, 1–100 characters. |
| description | string | No | Internal description, up to 500 characters. |
| subject | string | Email only | Subject line, up to 255 characters. Omitting it on an email template returns 400 "subject is required for email templates". |
| htmlContent | string | Email only | HTML body, up to 200,000 characters. An email template needs htmlContent or textContent. |
| textContent | string | SMS only | Up to 5,000 characters. Required on an SMS template; on email it is the plain-text alternative. |
| variables | string[] | No | Up to 50 placeholder names, each up to 64 characters, without braces — e.g. "firstName". |
Headers
| Field | Type | Required | Description |
|---|---|---|---|
| Idempotency-Key | string | No | Up to 255 characters — see Idempotency. |
Example request
curl -X POST "https://server.quickgrow.ai/api/v1/templates" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"name": "Order Confirmation",
"subject": "Your order {{orderId}}",
"htmlContent": "<p>Hi {{firstName}}, your order {{orderId}} is confirmed.</p>",
"variables": [
"firstName",
"orderId"
]
}'Example response
{
"data": {
"id": "3f82ab61-d9c0-4e57-9b24-8a05c1f76e3d",
"channel": "email",
"locale": "en",
"name": "Order Confirmation",
"description": null,
"subject": "Your order {{orderId}}",
"variables": ["firstName", "orderId"],
"isActive": true,
"createdAt": "2026-07-15T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:11.000Z"
}
}Create an SMS template
curl -X POST "https://server.quickgrow.ai/api/v1/templates" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "sms",
"name": "Flash Sale",
"textContent": "Hi {{firstName}}, sale on!",
"variables": [
"firstName"
]
}'{
"data": {
"id": "6a90de25-c4f1-4b80-a915-7e34c06d28bf",
"channel": "sms",
"locale": "en",
"name": "Flash Sale",
"description": null,
"variables": ["firstName"],
"isActive": true,
"createdAt": "2026-07-15T09:31:02.000Z",
"updatedAt": "2026-07-15T09:31:02.000Z"
}
}List templates
/templatestemplates:readLists every template on your account across all three channels — those created through POST /templates and those created in the QuickGrow AI dashboard. Any id here is what POST /messages accepts as templateId, provided the channels match.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| channel | string | No | email, sms or whatsapp. Omit it to get all three. |
Example request
curl "https://server.quickgrow.ai/api/v1/templates?channel=email" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": [
{
"id": "2b90ef17-c8a4-4d63-9a10-5e7fb2c04836",
"channel": "email",
"locale": "en",
"name": "Invoice ready",
"description": "Sent when a new invoice is generated",
"subject": "Invoice {{invoiceNumber}} is ready",
"variables": ["firstName", "invoiceNumber", "amountDue"],
"isActive": true,
"createdAt": "2026-06-02T10:15:00.000Z",
"updatedAt": "2026-07-01T08:30:00.000Z"
},
{
"id": "7c31a9d4-b2e6-4f05-8c93-1d6e05a7b284",
"channel": "sms",
"locale": "en",
"name": "Login OTP",
"description": "One-time passcode for sign-in",
"variables": ["code"],
"isActive": true,
"createdAt": "2026-05-20T14:00:00.000Z",
"updatedAt": "2026-05-20T14:00:00.000Z"
},
{
"id": "9d05cc41-f7b8-4a29-b356-0e1c47d92a60",
"channel": "whatsapp",
"locale": "bn",
"name": "Appointment reminder",
"description": "Reminds the customer of an upcoming appointment",
"variables": ["firstName", "date", "time"],
"isActive": true,
"whatsappStatus": "APPROVED",
"createdAt": "2026-04-11T09:00:00.000Z",
"updatedAt": "2026-06-28T11:45:00.000Z"
}
]
}Retrieve a template
/templates/{id}templates:readReturns one template and the variables it expects. The lookup covers all three channels, so you do not need to know which channel an id belongs to.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Template id. |
Example request
curl "https://server.quickgrow.ai/api/v1/templates/2b90ef17-c8a4-4d63-9a10-5e7fb2c04836" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": {
"id": "2b90ef17-c8a4-4d63-9a10-5e7fb2c04836",
"channel": "email",
"locale": "en",
"name": "Invoice ready",
"description": "Sent when a new invoice is generated",
"subject": "Invoice {{invoiceNumber}} is ready",
"variables": ["firstName", "invoiceNumber", "amountDue"],
"isActive": true,
"createdAt": "2026-06-02T10:15:00.000Z",
"updatedAt": "2026-07-01T08:30:00.000Z"
}
}Analytics
One endpoint, returning per-channel counts over a date range for everything on your account — API sends, campaigns and automations alike.
Read the counters carefully: they bucket messages by the status each one currently holds, so they are mutually exclusive rather than cumulative. A message that reached delivered is no longer counted under sent. To get a delivery rate, compare a bucket against total — do not add the buckets up.
Message analytics
/analytics/messagesanalytics:readCounts messages per channel over a date range, bucketed by the status each message currently holds. With no parameters the range runs from the start of the current month to now.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| startDate | string | No | ISO 8601 start of the range. Defaults to midnight on the 1st of the current month, in the server’s timezone. |
| endDate | string | No | ISO 8601 end of the range. Defaults to now. |
Per-channel counters
| Field | Type | Required | Description |
|---|---|---|---|
| total | integer | — | Every message created on that channel in the range, whatever its status. |
| sent | integer | — | Currently at "sent" — left the platform, no delivery confirmation yet. |
| delivered | integer | — | Currently at "delivered". |
| opened | integer | — | Currently at "opened". Email only in practice. |
| clicked | integer | — | Currently at "clicked". Email only. |
| bounced | integer | — | Currently at "bounced". |
| failed | integer | — | Currently at "failed" or "rejected". |
Example request
curl "https://server.quickgrow.ai/api/v1/analytics/messages?startDate=2026-07-01T00:00:00Z&endDate=2026-07-15T23:59:59Z" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": {
"range": {
"startDate": "2026-07-01T00:00:00.000Z",
"endDate": "2026-07-15T23:59:59.000Z"
},
"channels": {
"email": { "total": 1284, "sent": 64, "delivered": 349, "opened": 573, "clicked": 262, "bounced": 21, "failed": 14 },
"sms": { "total": 642, "sent": 9, "delivered": 631, "opened": 0, "clicked": 0, "bounced": 0, "failed": 2 },
"whatsapp": { "total": 188, "sent": 4, "delivered": 26, "opened": 154, "clicked": 0, "bounced": 0, "failed": 2 }
}
}
}Webhooks
Instead of polling GET /messages/{id}, register an endpoint and QuickGrow AI pushes delivery events to you as they happen. Only messages sent through this API produce events — your campaign and automation traffic never reaches your endpoint, however busy the account gets.
These endpoints use the same API key, under the webhooks:manage scope, but they sit outside the OpenAPI document and return the platform's older error shape rather than theerror envelope described above.
Events & payload
| Event | Triggered when |
|---|---|
| message.sent | The message left the platform. Fired straight from the request for email, and from the worker for SMS and WhatsApp. |
| message.delivered | The recipient server or handset confirmed delivery. |
| message.failed | Sending failed terminally. failureReason and failureCode on the message say why. |
| message.bounced | The recipient address rejected the message. Email only. |
Those four are the complete list. There is no event for an open, a click or a WhatsApp read receipt — those statuses exist on the message, so read them from GET /messages if you need them.
Deliveries are HTTP POST requests with a JSON body. The embedded data.message has exactly the same shape as GET /messages/{id}, your metadata included, so you can match an event to your own records without a lookup table.
{
"id": "evt_0f4b91d7-2c68-4e35-a7b0-83d15e6c2941",
"event": "message.delivered",
"createdAt": "2026-07-15T09:24:15.000Z",
"data": {
"message": {
"id": "9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846",
"channel": "email",
"status": "delivered",
"to": "[email protected]",
"tags": ["order-confirmation"],
"metadata": { "orderId": "1042" },
"createdAt": "2026-07-15T09:24:11.000Z",
"sentAt": "2026-07-15T09:24:12.000Z",
"deliveredAt": "2026-07-15T09:24:15.000Z",
"failedAt": null,
"failureReason": null,
"failureCode": null
}
}
}Delivery headers
| Header | Description |
|---|---|
| X-QuickGrow-Event | The event name, so you can route without parsing the body. |
| X-QuickGrow-Signature | The timestamped signature: t=<unix seconds>,v1=<hex HMAC-SHA256>. Use this one. |
| X-Webhook-Event | Legacy duplicate of X-QuickGrow-Event. |
| X-Webhook-Signature | Legacy signature — HMAC-SHA256 of the raw body alone, with no timestamp and therefore no replay protection. |
| User-Agent | Always QuickGrow-Webhooks/1.0. |
How delivery behaves
| Behaviour | |
|---|---|
| Which messages fire events | Only messages sent through this API. Campaign and automation traffic never produces webhook events, however busy your account is. |
| Attempts | Five in total: one immediately, then after 5 seconds, 1 minute, 5 minutes and 30 minutes. A 2xx at any point stops the sequence. |
| What counts as success | Any status from 200 to 299. Everything else, including a redirect, is a failure — redirects are not followed. |
| Timeout | 10 seconds per attempt. Acknowledge first and do your work afterwards; a slow handler burns retries. |
| Ordering | Not guaranteed. A retried message.sent can arrive after message.delivered, so treat each event as a state assertion and ignore one that moves the status backwards. |
| At-least-once | A delivery your endpoint acknowledged too slowly is retried, so the same event id can arrive twice. Deduplicate on the event id, or make the handler idempotent. |
| Reconciling | Retries are held in memory, so a deploy or restart during the backoff window drops the pending ones. GET /messages is the source of truth — sweep it periodically rather than assuming every event arrived. |
GET /messages as the source of truth.Create a webhook
/webhookswebhooks:manageRegisters an endpoint to receive event notifications. The signing secret — 48 hex characters, with no prefix — is returned once, here, and never again on a read.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | A label for this endpoint, e.g. "Production delivery events". |
| url | string | Yes | The URL that will receive POST deliveries. |
| events | string[] | Yes | The events to subscribe to. See the table above. |
| isActive | boolean | No | Send false to create the subscription paused. Defaults to true. |
Example request
curl -X POST "https://server.quickgrow.ai/api/v1/webhooks" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Production delivery events",
"url": "https://api.example-app.com/hooks/quickgrow",
"events": [
"message.delivered",
"message.failed",
"message.bounced"
]
}'Example response
{
"data": {
"webhook": {
"id": "4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b",
"name": "Production delivery events",
"url": "https://api.example-app.com/hooks/quickgrow",
"events": ["message.delivered", "message.failed", "message.bounced"],
"isActive": true,
"totalSent": 0,
"totalFailed": 0,
"lastSentAt": null,
"lastFailedAt": null,
"createdAt": "2026-07-15T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:11.000Z",
"hasSecret": true
},
"secret": "f3a91c7e28d4b6a0c5e19f72b8d40a6c1e93f7a5d2b60c84"
}
}List webhooks
/webhookswebhooks:manageLists every webhook subscription on your account, newest first. Secrets are never included; hasSecret tells you one is set.
Response fields (per item)
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | — | UUID of the subscription. |
| name | string | — | The label you gave it. |
| url | string | — | Where deliveries are POSTed. |
| events | string[] | — | The events this subscription is subscribed to. |
| isActive | boolean | — | False means paused: nothing is delivered and nothing is queued. |
| totalSent | integer | — | Deliveries that eventually succeeded. Counted once per event, not once per retry. |
| totalFailed | integer | — | Deliveries that exhausted all five attempts. |
| lastSentAt | string | null | — | When a delivery last succeeded. |
| lastFailedAt | string | null | — | When a delivery last gave up. |
| hasSecret | boolean | — | Whether a signing secret is set. The secret itself is never returned on a read. |
Example request
curl "https://server.quickgrow.ai/api/v1/webhooks" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": [
{
"id": "4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b",
"name": "Production delivery events",
"url": "https://api.example-app.com/hooks/quickgrow",
"events": ["message.delivered", "message.failed", "message.bounced"],
"isActive": true,
"totalSent": 4128,
"totalFailed": 6,
"lastSentAt": "2026-07-15T09:24:15.000Z",
"lastFailedAt": "2026-07-02T22:10:41.000Z",
"createdAt": "2026-06-01T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:15.000Z",
"hasSecret": true
}
]
}Retrieve a webhook
/webhooks/{id}webhooks:manageReturns one subscription with its delivery counters — the quickest way to see whether your endpoint has been failing.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Webhook id. |
Example request
curl "https://server.quickgrow.ai/api/v1/webhooks/4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": {
"id": "4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b",
"name": "Production delivery events",
"url": "https://api.example-app.com/hooks/quickgrow",
"events": ["message.delivered", "message.failed", "message.bounced"],
"isActive": true,
"totalSent": 4128,
"totalFailed": 6,
"lastSentAt": "2026-07-15T09:24:15.000Z",
"lastFailedAt": "2026-07-02T22:10:41.000Z",
"createdAt": "2026-06-01T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:15.000Z",
"hasSecret": true
}
}Update a webhook
/webhooks/{id}webhooks:manageChanges the name, URL or subscribed events, or pauses the subscription with isActive: false. Only the fields you send are touched.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Webhook id. |
Example request
curl -X PATCH "https://server.quickgrow.ai/api/v1/webhooks/4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b" \
-H "X-API-Key: mk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"events": [
"message.delivered",
"message.failed"
],
"isActive": true
}'Example response
{
"data": {
"id": "4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b",
"name": "Production delivery events",
"url": "https://api.example-app.com/hooks/quickgrow",
"events": ["message.delivered", "message.failed", "message.bounced"],
"isActive": true,
"totalSent": 4128,
"totalFailed": 6,
"lastSentAt": "2026-07-15T09:24:15.000Z",
"lastFailedAt": "2026-07-02T22:10:41.000Z",
"createdAt": "2026-06-01T09:24:11.000Z",
"updatedAt": "2026-07-15T09:24:15.000Z",
"hasSecret": true
}
}Delete a webhook
/webhooks/{id}webhooks:manageDeletes a subscription and its stored delivery logs. To stop deliveries without losing the history, pause it with isActive: false instead.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Webhook id. |
Example request
curl -X DELETE "https://server.quickgrow.ai/api/v1/webhooks/4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b" \
-H "X-API-Key: mk_your_api_key"Returns 204 No Content with an empty body.
Send a test event
/webhooks/{id}/testwebhooks:managePOSTs a webhook.test event to your endpoint and reports back what your server answered, including the first 2,000 characters of its response body. The best way to prove connectivity before you go live.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Webhook id. |
Example request
curl -X POST "https://server.quickgrow.ai/api/v1/webhooks/4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b/test" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": {
"success": true,
"statusCode": 200,
"responseTime": 184,
"responseBody": "{\"ok\":true}"
}
}Regenerate the secret
/webhooks/{id}/regenerate-secretwebhooks:manageIssues a new 48-character signing secret and returns it once. Use this if a secret leaks — you do not need to delete and re-create the subscription.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Webhook id. |
Example request
curl -X POST "https://server.quickgrow.ai/api/v1/webhooks/4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b/regenerate-secret" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": {
"secret": "c81f70d2a9e4b35c06f1a8d7e293b40c5f6a1d820e74b3c9"
}
}Delivery logs
/webhooks/{id}/logswebhooks:manageRecent delivery attempts for one subscription, newest first — what your endpoint answered, how long it took, and how many attempts it needed.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Webhook id. |
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| page | integer | No | Page number. Defaults to 1. |
| limit | integer | No | Page size. Defaults to 20. |
Example request
curl "https://server.quickgrow.ai/api/v1/webhooks/4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b/logs?page=1&limit=20" \
-H "X-API-Key: mk_your_api_key"Example response
{
"data": [
{
"id": "01a2b3c4-d5e6-4f70-8912-3a4b5c6d7e8f",
"webhookId": "4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b",
"event": "message.delivered",
"payload": { "id": "evt_0f4b91d7-2c68-4e35-a7b0-83d15e6c2941", "event": "message.delivered", "...": "..." },
"statusCode": 200,
"responseBody": "{\"ok\":true}",
"responseTime": 184,
"success": true,
"attempts": 1,
"createdAt": "2026-07-15T09:24:15.000Z"
},
{
"id": "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081",
"webhookId": "4e8b21c9-a7f3-4d16-b092-5c8e37a04f1b",
"event": "message.failed",
"payload": { "id": "evt_6c2b83f0-91d4-4a57-b8e2-70fa1c94d365", "...": "..." },
"statusCode": 500,
"responseBody": "Internal Server Error",
"responseTime": 10004,
"success": false,
"attempts": 5,
"createdAt": "2026-07-15T08:12:40.000Z"
}
],
"total": 2,
"page": 1,
"limit": 20,
"pages": 1
}Signature verification
Your endpoint is a public URL, so anyone can POST to it. Verifying the signature is what tells you a delivery really came from QuickGrow AI and was not altered on the way. The X-QuickGrow-Signature header carries a Unix timestamp t and a signature v1: the hex-encoded HMAC-SHA256 of `${t}.${rawRequestBody}`, computed with the 48-character secret returned when you created the subscription.
X-QuickGrow-Event: message.delivered
X-QuickGrow-Signature: t=1784107455,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
# Also sent, for older integrations. No timestamp, so no replay protection.
X-Webhook-Event: message.delivered
X-Webhook-Signature: 9c1f0a74e6b2d385c47f19a0e83b56d21c40f7a95e6b83d012c74af518e390b6- Parse
tandv1from the header. - Reject the delivery if
|now − t| > 300seconds (replay protection). - Recompute HMAC-SHA256 over
<t>.<raw body>with your secret and compare it tov1using a constant-time comparison.
import crypto from 'crypto';
function verifyQuickGrowSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => part.split('=')),
);
// 1. Reject stale deliveries (replay protection)
const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
if (ageSeconds > 300) return false;
// 2. Recompute HMAC-SHA256 over "<timestamp>.<raw body>"
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
// 3. Constant-time comparison
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: verify against the RAW body, not the parsed JSON
app.post(
'/hooks/quickgrow',
express.raw({ type: 'application/json' }),
(req, res) => {
const valid = verifyQuickGrowSignature(
req.body.toString('utf8'),
req.get('X-QuickGrow-Signature'),
process.env.QUICKGROW_WEBHOOK_SECRET,
);
if (!valid) return res.status(400).send('Invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// if (event.event === 'message.delivered') { ... }
res.sendStatus(200);
},
);A legacy X-Webhook-Signature header is sent alongside it for older integrations: the hex-encoded HMAC-SHA256 of the raw body alone, with no timestamp and therefore no replay protection. Use X-QuickGrow-Signature for anything you are building now.
POST /webhooks/{id}/test signs with the legacy header only, so a passing test does not prove your X-QuickGrow-Signature check works. Verify that one against a real event — send yourself a message and inspect what arrives.OpenAPI & tooling
There is no published client library — nothing to npm install or pip install. The API is plain REST over HTTPS with a header for auth, so your language's standard HTTP client is enough; every example on this page is written that way on purpose.
If you would rather have typed methods than hand-written requests, generate a client from the OpenAPI document. It stays in step with the API, which a hand-rolled wrapper will not.
OpenAPI specification
The machine-readable contract for the 13 endpoints above. Import it into Postman or Insomnia, or feed it to a code generator. Note that the webhook management endpoints are not part of this document — write those calls by hand.
Generate a client
# TypeScript / JavaScript
npx @hey-api/openapi-ts \
-i https://server.quickgrow.ai/api/public-docs/openapi.json \
-o ./src/quickgrow
# Python
openapi-python-client generate --url https://server.quickgrow.ai/api/public-docs/openapi.json
# Anything else — Java, Go, C#, PHP, Ruby, Dart …
openapi-generator-cli generate \
-i https://server.quickgrow.ai/api/public-docs/openapi.json \
-g <language> \
-o ./quickgrow-clientQuestions or stuck on an integration? Start a live chat or submit a ticket.