On this page
API v1REST · JSON

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

All endpoints are relative to
https://server.quickgrow.ai/api/v1
Resource ids are plain UUIDs — there is no msg_, 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.

1

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.

2

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>"
  }'
Response · 201 Created
{
  "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
  }
}
3

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

GroupEndpointWhat it does
MessagesPOST/messagesSend one email, SMS or WhatsApp message.
MessagesGET/messages/{id}Read one message and its delivery timestamps.
MessagesGET/messagesList messages, newest first, with a cursor.
ContactsPOST/contactsCreate one contact.
ContactsPOST/contacts/bulkCreate up to 1,000 contacts, with a per-row result.
ContactsGET/contactsList contacts with search, filters and page numbers.
ContactsGET/contacts/{id}Read one contact.
ContactsPATCH/contacts/{id}Change some fields on a contact.
ContactsDELETE/contacts/{id}Remove a contact from your account.
TemplatesPOST/templatesCreate an email or SMS template.
TemplatesGET/templatesList every template, all channels.
TemplatesGET/templates/{id}Read one template and the variables it expects.
AnalyticsGET/analytics/messagesDelivery 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 environment and no test mode. Every request you make with a real key acts on your live account and spends real credit.

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.

Request headers
# 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_2f8a91c7e28d4b6a0c5e19f7b4d63a08c71e5f92d0a4b8c63e17f9d25a08b4c6

Scopes

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.

ScopeGrants
messages:sendSend messages (POST /messages).
messages:readRead message status and message lists.
contacts:readList and retrieve contacts.
contacts:writeCreate, update and delete contacts, including the bulk endpoint. Deleting uses this scope, not a separate delete scope.
templates:readList and retrieve message templates on every channel.
templates:writeCreate message templates (POST /templates).
analytics:readRead aggregate delivery analytics.
webhooks:manageList, read, create, update, delete, test and re-key webhook subscriptions.
An API key is a password for your account. Use it from server-side code only — never from a browser, a mobile app, or anywhere a customer could read it — and revoke it the moment you suspect it leaked. Each key can be revoked on its own, so one key per environment and one per service keeps a leak survivable.

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

200 / 201
{
  "data": {
    "id": "9f8a3bc2-e41d-4a70-b3c5-1d7e0a52f846",
    "status": "sent"
  }
}

List envelope — cursor

GET /messages · 200
{
  "data": [ { "...": "..." }, { "...": "..." } ],
  "pagination": { "cursor": "7d2c4fa1-b8e9-4c30-a5f2-91b60d3e4478", "hasMore": true }
}

List envelope — pages

GET /contacts · 200
{
  "data": [ { "...": "..." } ],
  "pagination": { "page": 1, "limit": 20, "total": 184, "totalPages": 10 }
}
The two list endpoints paginate differently. Messages use a cursor — the id of the last row on the page, which you pass back as ?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

4xx / 5xx
{
  "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.

CodeHTTP statusDescription
invalid_request400The body or query string failed validation. message names the parameter that is wrong; several validation failures are joined with "; ".
invalid_recipient400"to" is not a valid email address (channel "email") or a 7–15 digit E.164 phone number (channels "sms" and "whatsapp").
invalid_attachment400An attachment was sent on a channel other than "email", or its content did not decode as base64.
attachment_too_large400The attachments decode to more than 7 MB in total. The message says how large they actually were.
unauthorized401The API key is missing, malformed, or has been revoked.
insufficient_credits402Your 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_scope403The key was not granted the scope this endpoint requires.
template_not_found404No 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_found404No contact with this id exists on your account.
message_not_found404No message with this id exists on your account.
webhook_not_found404No webhook subscription with this id exists on your account.
not_found404The requested resource does not exist.
conflict409A record with these values already exists — most often a contact whose email or phone you already hold.
idempotency_conflict409The 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_blocked422The 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_limited429Too many requests. Check the X-RateLimit-* headers and retry after the reset timestamp.
internal_error500Something went wrong on our side. Safe to retry — reuse your Idempotency-Key to avoid duplicates.
Retry 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.

HeaderDescription
X-RateLimit-LimitRequests allowed per minute for this key. 1000 unless a different limit was set on the key.
X-RateLimit-RemainingRequests left in the current window. Floors at 0 rather than going negative.
X-RateLimit-ResetUnix 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.

429 Too Many Requests
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.
Derive the key from the thing you are sending about, not from the attempt — a fresh UUID per retry defeats the whole mechanism, because no two attempts will ever match. Something like 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.

StatusFilterableMeaning
queuedYesAccepted and waiting for a worker. The starting status for SMS and WhatsApp.
sendingYesA worker has picked it up and is handing it to the provider.
sentYesAccepted by the provider. The starting status for email, which sends synchronously.
deliveredYesDelivery confirmed by the recipient server or handset.
openedYesThe recipient opened the message. Email only, and only when open tracking fires.
clickedYesThe recipient clicked a tracked link. Email only.
bouncedYesThe recipient address rejected the message.
failedYesSending failed terminally — read failureReason and failureCode.
readNoWhatsApp read receipt. Can be returned, but ?status=read is rejected by the list filter.
rejectedNoRefused by the provider. Counts as failed in analytics. Not accepted by the list filter.
unsubscribedNoThe recipient opted out. Not accepted by the list filter.
complainedNoThe recipient marked it as spam. Not accepted by the list filter.

Send a message

POST/messagesmessages:send

Sends 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".

SMS and WhatsApp sends run through the compliance gate before they are queued: active consent (SMS), the quiet-hours window for the recipient country, and a per-contact frequency cap. A blocked send returns 422 send_blocked and nothing is delivered. Email is not gated by any of the three. Do not design an OTP or alert flow that assumes an SMS will go out at any hour — build a fallback, and read the failure reason.
A WhatsApp send through this API is delivered as free text, not as a Meta template, whatever templateId you pass. Meta only permits free text inside the 24-hour window opened by the customer’s own last inbound message, so a WhatsApp send to someone who has not written to you recently is refused with 422 send_blocked.
Sending is prepaid, and it stops dead at zero. Email spends one credit per recipient; SMS spends one credit per segment, from the masked or the non-masked counter depending on the sender. When the counter for the channel cannot cover the send, the request is refused with 402 insufficient_credits before anything is dispatched — the message is not queued, not retried, and will not go out later on its own. Buy credits and send it again. Handle 402 as a stop, not as a transient error: retrying the same request with the same Idempotency-Key will keep returning 402 until the balance covers it. WhatsApp spends no QuickGrow credits at all; Meta bills you for it directly.
Unknown recipients are stored as contacts with source "api", so every recipient you message shows up in GET /contacts afterwards. A contact is resolved or created on every send, including email.
Use metadata to attach your own reference (order id, booking id, user id). It is returned verbatim on every read and on every webhook payload, so you never need a mapping table.
Either send inline content or send templateId — but not a template name. A message that carries neither is rejected with 400 invalid_request.

Body parameters

FieldTypeRequiredDescription
channelstringYesDelivery channel: "email", "sms" or "whatsapp".
tostringYesAn email address for channel "email"; a 7–15 digit E.164 phone number for "sms" and "whatsapp" (e.g. +8801700000000). Max 320 characters.
subjectstringEmail onlySubject line, max 500 characters. Required for email unless you pass templateId. Ignored on SMS and WhatsApp.
htmlstringEmail onlyHTML body. Email needs html or text (or a templateId).
textstringSMS / WhatsAppThe message body for SMS and WhatsApp — required there unless you pass templateId. On email it is the plain-text alternative.
fromstringNoSender 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.
fromNamestringNoSender 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.
replyTostringNoReply-to address (email only).
templateIdstringNoThe 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.
variablesobjectNoString 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.
tagsstring[]NoLabels stored on the message and returned on reads. They are not a filter on GET /messages.
metadataobjectNoYour own reference data, returned verbatim on reads and webhooks.
attachmentsobject[]NoEmail only — SMS and WhatsApp attachments are rejected with 400 invalid_attachment. At most 10 files and 7 MB decoded across all of them.
attachments[].filenamestringYesMax 255 characters. Path separators and control characters are rejected.
attachments[].contentstringYesThe file, base64-encoded.
attachments[].contentTypestringNoMIME type, e.g. application/pdf.
attachments[].cidstringNoContent-ID, so the HTML body can reference the file inline.

Headers

FieldTypeRequiredDescription
Idempotency-KeystringNoUp 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

Response · 201 Created
{
  "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"
    }
  }'
Response · 201 Created
{
  "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

GET/messages/{id}messages:read

Returns 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

FieldTypeRequiredDescription
idstringYesThe id returned by POST /messages.

Response fields

FieldTypeRequiredDescription
idstringUUID. Not prefixed — do not pattern-match on "msg_".
channelstringLower-case: "email", "sms" or "whatsapp".
statusstringLower-case delivery status. See the status table.
tostring | nullThe recipient you passed in. Null on messages that were not sent through this API.
tagsstring[]The tags you sent. Empty array if you sent none.
metadataobjectYour own metadata object, returned verbatim. Empty object if you sent none.
createdAtstringISO 8601, UTC. When the message row was created.
sentAtstring | nullWhen it left the platform. Null while queued.
deliveredAtstring | nullWhen the receiving server or handset confirmed delivery.
failedAtstring | nullWhen it failed terminally.
failureReasonstring | nullHuman-readable failure text from the provider or the compliance gate.
failureCodestring | nullProvider 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

Response · 200 OK
{
  "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

GET/messagesmessages:read

Lists 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.

This lists every message on the account, not only API sends — campaign and automation traffic appears here too. Those rows have to: null and an empty metadata object, because there was no API caller to record. Filter on your own tags-in-metadata if you need to isolate your integration’s traffic.
tags cannot be filtered on. The six parameters below are the whole filter set.

Query parameters

FieldTypeRequiredDescription
channelstringNoOne of email, sms, whatsapp. Anything else is 400.
statusstringNoOne of queued, sending, sent, delivered, opened, clicked, bounced, failed. The other statuses a message can hold — read, rejected, unsubscribed, complained — are not accepted here.
createdAfterstringNoISO 8601. Messages created at or after this moment.
createdBeforestringNoISO 8601. Messages created at or before this moment.
limitintegerNoPage size, 1–100. Defaults to 20.
cursorstringNoThe 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

Response · 200 OK
{
  "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.

StatusMeaning
activeThe default for every contact you create. The only status that campaigns will target.
unsubscribedThe contact opted out. Set by a one-click email unsubscribe, or by hand in the dashboard.
bouncedMail to this address hard-bounced.
complainedThe contact reported a message as spam.
quarantinedHeld back by abuse prevention pending review.

Create a contact

POST/contactscontacts:write

Creates 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".

A contact whose email or phone you already hold returns 409 conflict rather than updating the existing record. Use PATCH /contacts/{id} to change one.
There is no cap on how many contacts you may hold. Contacts spend no credits — credits are spent by messages sent, not by list size — so a create is never refused for being one contact too many.
Creating a contact through this API does not fire any automation. Only a CSV import and the date-based trigger start journeys on their own.

Body parameters

FieldTypeRequiredDescription
emailstringOne of email / phoneContact email address. Must be unique on your account — a repeat returns 409 conflict.
phonestringOne of email / phonePhone number in E.164 format. Also unique on your account.
firstNamestringNoFirst name, max 100 characters.
lastNamestringNoLast name, max 100 characters.
tagsstring[]NoLabels you can filter and segment on. Adding a tag here does not start an automation.
sourcestringNoFree 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".
customFieldsobjectNoYour own key–value data, up to 10 KB when serialised; a larger object is dropped rather than rejected. Segments cannot read these fields.

Headers

FieldTypeRequiredDescription
Idempotency-KeystringNoUp 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

Response · 201 Created
{
  "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

POST/contacts/bulkcontacts:write

Imports 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.

Rows are processed one at a time and a bad row does not stop the batch. A row that duplicates an existing contact comes back with status "duplicate" and an error message — not with an id, so do not read results[].id on a duplicate.
No contact quota can stop the batch: contacts are not metered on any package, so a run of 1,000 rows is limited only by what is in the request.
This is not the CSV importer. Contacts created here do not fire the contact-created automation trigger.

Body parameters

FieldTypeRequiredDescription
contactsobject[]YesBetween 1 and 1,000 contact objects, each with the same fields as POST /contacts.

Headers

FieldTypeRequiredDescription
Idempotency-KeystringNoUp 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

Response · 200 OK
{
  "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

GET/contactscontacts:read

Lists contacts with page numbers, search and filters. Note the pagination shape here is page-based, unlike GET /messages which is cursor-based.

Contact names, emails and phone numbers are stored encrypted, so search runs against the account’s indexed copy — it will not do partial matches on an email the way a plain SQL LIKE would.

Query parameters

FieldTypeRequiredDescription
pageintegerNoPage number, from 1. Defaults to 1.
limitintegerNoPage size, 1–100. Defaults to 20.
searchstringNoMatches against name, email and phone.
statusstringNoOne of ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED, QUARANTINED. Upper case here, even though the response returns the status lower-cased.
tagsstringNoComma-separated tags. Matches a contact carrying any of them.
sourcestringNoExact match on source, e.g. "api" or "checkout".
createdAfterstringNoISO 8601 — contacts created after this moment.
createdBeforestringNoISO 8601 — contacts created before this moment.
minEngagementScoreintegerNoOnly contacts at or above this engagement score.
sortBystringNoColumn to sort on. Defaults to createdAt.
sortOrderstringNo"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

Response · 200 OK
{
  "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

GET/contacts/{id}contacts:read

Returns a single contact by ID.

Path parameters

FieldTypeRequiredDescription
idstringYesContact 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

Response · 200 OK
{
  "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

PATCH/contacts/{id}contacts:write

Partially updates a contact. Accepts the same fields as POST /contacts; only the fields you send are changed.

tags and customFields are replaced, not merged. Send the full array or object you want the contact to end up with.
Changing a tag here does not fire the tag-added automation trigger.

Path parameters

FieldTypeRequiredDescription
idstringYesContact 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

Response · 200 OK
{
  "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

DELETE/contacts/{id}contacts:write

Removes 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.

Because the identifier is retired rather than erased, re-creating a contact with the same phone number succeeds, while re-creating one with the same email address still returns 409 conflict.
This is not a right-to-erasure request. Erasure is handled by support, not by this endpoint.

Path parameters

FieldTypeRequiredDescription
idstringYesContact 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

POST/templatestemplates:write

Creates 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.

The returned id is immediately usable as templateId on POST /messages, and the message channel must match the template channel.
WhatsApp templates cannot be created here. QuickGrow does not submit templates to Meta — you draft and approve them in your own WhatsApp Business account and they are synced back. A request with channel "whatsapp" is rejected at validation.
variables is a declaration for your own tooling, not a validation rule. A placeholder you forget to declare is still substituted, and one with no matching value at send time is left in the message text as {{name}} rather than blanked.

Body parameters

FieldTypeRequiredDescription
channelstringYes"email" or "sms". "whatsapp" is rejected.
namestringYesTemplate name, 1–100 characters.
descriptionstringNoInternal description, up to 500 characters.
subjectstringEmail onlySubject line, up to 255 characters. Omitting it on an email template returns 400 "subject is required for email templates".
htmlContentstringEmail onlyHTML body, up to 200,000 characters. An email template needs htmlContent or textContent.
textContentstringSMS onlyUp to 5,000 characters. Required on an SMS template; on email it is the plain-text alternative.
variablesstring[]NoUp to 50 placeholder names, each up to 64 characters, without braces — e.g. "firstName".

Headers

FieldTypeRequiredDescription
Idempotency-KeystringNoUp 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

Response · 201 Created
{
  "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"
    ]
  }'
Response · 201 Created
{
  "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

GET/templatestemplates:read

Lists 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.

There is no pagination on this endpoint and no page or limit parameter — it returns every template on the account in one array, newest-updated first within each channel.
subject appears only on email templates. whatsappStatus appears only on WhatsApp templates and is upper case: APPROVED, PENDING, REJECTED or DISABLED. locale is a BCP-47 tag; it reflects the real language on WhatsApp templates and is "en" on the others, which have no language column yet.

Query parameters

FieldTypeRequiredDescription
channelstringNoemail, 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

Response · 200 OK
{
  "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

GET/templates/{id}templates:read

Returns 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.

The template body itself is not returned — only its metadata and the variable names. Read or edit the content in the dashboard.

Path parameters

FieldTypeRequiredDescription
idstringYesTemplate 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

Response · 200 OK
{
  "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

GET/analytics/messagesanalytics:read

Counts 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.

These are not funnel counters. Each message is counted once in total and once in the bucket for its current status, so the buckets are mutually exclusive: a message that reached "delivered" is no longer counted under "sent", and one that was opened is counted only under "opened". Adding the buckets together will not reproduce a delivery rate — for that, compare the buckets you care about against total.
The buckets do not cover every status. Messages sitting at queued, sending, read, unsubscribed or complained count towards total but land in no bucket, which is why the buckets can sum to less than total. Rejected messages are folded into failed.
This counts everything on the account for the range — campaign and automation traffic as well as API sends. Credits spent are not reported here, and there is no per-campaign breakdown on this endpoint.
Both dates are parsed as given; an invalid date is not rejected, so validate your input before sending it.

Query parameters

FieldTypeRequiredDescription
startDatestringNoISO 8601 start of the range. Defaults to midnight on the 1st of the current month, in the server’s timezone.
endDatestringNoISO 8601 end of the range. Defaults to now.

Per-channel counters

FieldTypeRequiredDescription
totalintegerEvery message created on that channel in the range, whatever its status.
sentintegerCurrently at "sent" — left the platform, no delivery confirmation yet.
deliveredintegerCurrently at "delivered".
openedintegerCurrently at "opened". Email only in practice.
clickedintegerCurrently at "clicked". Email only.
bouncedintegerCurrently at "bounced".
failedintegerCurrently 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

Response · 200 OK
{
  "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

EventTriggered when
message.sentThe message left the platform. Fired straight from the request for email, and from the worker for SMS and WhatsApp.
message.deliveredThe recipient server or handset confirmed delivery.
message.failedSending failed terminally. failureReason and failureCode on the message say why.
message.bouncedThe 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.

Webhook delivery · POST body
{
  "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

HeaderDescription
X-QuickGrow-EventThe event name, so you can route without parsing the body.
X-QuickGrow-SignatureThe timestamped signature: t=<unix seconds>,v1=<hex HMAC-SHA256>. Use this one.
X-Webhook-EventLegacy duplicate of X-QuickGrow-Event.
X-Webhook-SignatureLegacy signature — HMAC-SHA256 of the raw body alone, with no timestamp and therefore no replay protection.
User-AgentAlways QuickGrow-Webhooks/1.0.

How delivery behaves

Behaviour
Which messages fire eventsOnly messages sent through this API. Campaign and automation traffic never produces webhook events, however busy your account is.
AttemptsFive 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 successAny status from 200 to 299. Everything else, including a redirect, is a failure — redirects are not followed.
Timeout10 seconds per attempt. Acknowledge first and do your work afterwards; a slow handler burns retries.
OrderingNot 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-onceA 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.
ReconcilingRetries 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.
Answer with any 2xx status quickly — write the event to a queue and do the real work afterwards. Your handler has ten seconds, and a slow acknowledgement spends a retry rather than buying time. Because retries are held in memory during the backoff window, a restart on our side can drop a pending one, so treat webhooks as a fast path and GET /messages as the source of truth.

Create a webhook

POST/webhookswebhooks:manage

Registers 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.

Note the response shape: the subscription is nested under data.webhook, and the secret sits beside it at data.secret. Every other webhook endpoint returns the subscription directly under data.
Use a public HTTPS URL. Deliveries pass an SSRF guard, so a private, loopback or link-local address is refused at delivery time even though the subscription itself was accepted.
Events you are not subscribed to are never queued. Subscribing to an event name that does not exist is accepted but will simply never fire — check it against the table above.

Body parameters

FieldTypeRequiredDescription
namestringYesA label for this endpoint, e.g. "Production delivery events".
urlstringYesThe URL that will receive POST deliveries.
eventsstring[]YesThe events to subscribe to. See the table above.
isActivebooleanNoSend 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

Response · 201 Created
{
  "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

GET/webhookswebhooks:manage

Lists every webhook subscription on your account, newest first. Secrets are never included; hasSecret tells you one is set.

No pagination and no filters — the full list comes back in one array.

Response fields (per item)

FieldTypeRequiredDescription
idstringUUID of the subscription.
namestringThe label you gave it.
urlstringWhere deliveries are POSTed.
eventsstring[]The events this subscription is subscribed to.
isActivebooleanFalse means paused: nothing is delivered and nothing is queued.
totalSentintegerDeliveries that eventually succeeded. Counted once per event, not once per retry.
totalFailedintegerDeliveries that exhausted all five attempts.
lastSentAtstring | nullWhen a delivery last succeeded.
lastFailedAtstring | nullWhen a delivery last gave up.
hasSecretbooleanWhether 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

Response · 200 OK
{
  "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

GET/webhooks/{id}webhooks:manage

Returns one subscription with its delivery counters — the quickest way to see whether your endpoint has been failing.

Path parameters

FieldTypeRequiredDescription
idstringYesWebhook 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

Response · 200 OK
{
  "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

PATCH/webhooks/{id}webhooks:manage

Changes the name, URL or subscribed events, or pauses the subscription with isActive: false. Only the fields you send are touched.

events is replaced wholesale, not merged — send the complete list you want to end up with.
Pausing a subscription emails your account’s admins. It does not rotate the secret.

Path parameters

FieldTypeRequiredDescription
idstringYesWebhook 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

Response · 200 OK
{
  "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

DELETE/webhooks/{id}webhooks:manage

Deletes a subscription and its stored delivery logs. To stop deliveries without losing the history, pause it with isActive: false instead.

Path parameters

FieldTypeRequiredDescription
idstringYesWebhook 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

POST/webhooks/{id}/testwebhooks:manage

POSTs 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.

The test body is not shaped like a real event: it is { event, tenantId, timestamp, data: { webhookId, webhookName } }, with no id and no createdAt. Do not build a parser against it.
It is also signed only with the legacy X-Webhook-Signature header — a plain HMAC of the body with no timestamp. A passing test therefore does not prove your X-QuickGrow-Signature check works. Verify that one against a real event, or against a signature you compute yourself from the worked example below.
The attempt is written to the delivery log and counts towards totalSent or totalFailed like any other delivery, but it is not retried.

Path parameters

FieldTypeRequiredDescription
idstringYesWebhook 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

Response · 200 OK
{
  "data": {
    "success": true,
    "statusCode": 200,
    "responseTime": 184,
    "responseBody": "{\"ok\":true}"
  }
}

Regenerate the secret

POST/webhooks/{id}/regenerate-secretwebhooks:manage

Issues 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.

The change takes effect immediately and there is no overlap window: every delivery after this call is signed with the new secret only. Deploy the new secret to your endpoint first, accept both for a moment, then rotate.

Path parameters

FieldTypeRequiredDescription
idstringYesWebhook 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

Response · 200 OK
{
  "data": {
    "secret": "c81f70d2a9e4b35c06f1a8d7e293b40c5f6a1d820e74b3c9"
  }
}

Delivery logs

GET/webhooks/{id}/logswebhooks:manage

Recent delivery attempts for one subscription, newest first — what your endpoint answered, how long it took, and how many attempts it needed.

This response does not use the pagination object. The counters sit at the top level beside data, as total, page, limit and pages. It is the one place in the API where that happens.
Each row carries the full payload that was sent, so the logs double as a replay source when your endpoint was down.
One row covers a whole delivery, not one attempt: attempts is the number of tries it took, and statusCode and responseBody reflect the last of them.

Path parameters

FieldTypeRequiredDescription
idstringYesWebhook id.

Query parameters

FieldTypeRequiredDescription
pageintegerNoPage number. Defaults to 1.
limitintegerNoPage 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

Response · 200 OK
{
  "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.

Signature header
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
  1. Parse t and v1 from the header.
  2. Reject the delivery if |now − t| > 300 seconds (replay protection).
  3. Recompute HMAC-SHA256 over <t>.<raw body> with your secret and compare it to v1 using a constant-time comparison.
Always verify against the raw request body bytes, before any JSON parsing or re-serialization — even a reordered key or changed whitespace produces a different signature.
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

Terminal
# 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-client
An API key reaches the 13 endpoints above plus webhook subscriptions, and nothing else. It cannot start an automation, run a campaign, or read the shared inbox — those live in the dashboard. If you need something the API does not expose, tell us what you are building rather than working around it.

Questions or stuck on an integration? Start a live chat or submit a ticket.