REST API
Everything the dashboard does, your own backend can do too. Open a ticket when a payment fails, answer it from the tool your team already lives in, keep customer records in step with your database — and be told the moment any of it changes.
Base URL and authentication
Every endpoint lives under https://api.helpwing.app/api/v1, and almost every one is scoped to a project. Server-to-server callers authenticate with a project API key, sent as Authorization: Api-Key hw_live_your_secret_key.
curl -X POST https://api.helpwing.app/api/v1/projects/$HELPWING_PROJECT_ID/tickets/ \
-H "Authorization: Api-Key $HELPWING_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"subject": "Signup failed",
"customer_email": "ada@example.com",
"customer_name": "Ada Lovelace",
"source": "api",
"body_text": "Got a 500 on /signup."
}'Keys are created under Developers → API keys, by an owner or an admin. Each one belongs to a single project and comes in a test or a live flavour, and the secret is shown exactly once — only its hash is stored, so a key you did not write down is a key you replace. Revoking one takes effect on the next request.
A key belongs on your server. Unlike the widget's public pk_… key, it can read and write the whole project, so it must never reach browser code, a mobile bundle or a repository.
What a key can reach
Tickets, their messages, and customers — the three the API deliberately opens to machines. Everything else (members, billing, domains, widget settings, keys themselves) answers 403 — those exist for people, and they need a signed-in user. A key names its own project, so a request aimed at any other project id is a 404 whether or not that project exists.
Conventions
JSON in and JSON out, UUIDs for ids and ISO 8601 for timestamps. Paths end in a slash. Tickets also carry a reference — ten characters with no l, 1, o or 0 in it — which is what a human quotes back at you, and what you should print in your own interface.
Lists are paginated
Every list endpoint answers with the same envelope. Ask for a page with page and size it with page_size, up to 200; the default is 25.
{
"count": 137,
"page": 1,
"page_size": 25,
"total_pages": 6,
"next": "…?page=2",
"previous": null,
"results": [ … ]
}Ticket lists take status, priority, source, assignee, customer, tag, unassigned, is_spam, created_after and created_before, plus search across reference, subject, customer and message bodies, and ordering (prefix with - to reverse; the default is -last_message_at).
curl -G https://api.helpwing.app/api/v1/projects/$HELPWING_PROJECT_ID/tickets/ \
-H "Authorization: Api-Key $HELPWING_API_KEY" \
-d status=open -d priority=urgent \
-d search=signup -d ordering=-created_at -d page_size=50Failures all look the same
Any error, on any endpoint, is one envelope with a stable machine-readable code. Switch on that rather than on the message, which is written for a person and may be reworded.
{
"error": {
"code": "validation_error",
"message": "The request payload failed validation.",
"details": { "customer_email": ["Enter a valid email address."] }
}
}- 400validation_error
- The payload failed validation. `details` names the fields.
- 401authentication_required
- No key, an unknown key, or a key that has been revoked.
- 403permission_denied
- A valid key on an endpoint that keys cannot use.
- 404not_found
- No such object in this project, or a project id that is not this key’s.
- 409conflict
- The request contradicts the current state of the object.
- 429rate_limit_exceeded
- Too many requests. Back off and retry.
Opening a ticket
POST /projects/{project_pk}/tickets/ creates the ticket and, when you send a body, its first customer message. Name the customer one of three ways — by id, by email address, or by your own user id — and the record is created or reused rather than duplicated.
- subjectstring
- What the ticket is called in the inbox. Required.
- customer_emailstring
- Creates the customer, or reuses the one already holding that address. One of this, customer_id or customer_external_id is required.
- customer_iduuid
- An existing customer of this project, when you already know which one.
- customer_external_idstring
- Your own user id. Matches the same customer that identify() in the widget matches, so a ticket you open lands on the record they already have.
- customer_namestring
- Display name, used only when the customer is new or has none yet.
- body_textstring
- The first customer message. A ticket without one is an empty conversation.
- body_htmlstring
- The same message as HTML, when you have it.
- prioritystring
- low, normal, high or urgent. Defaults to normal.
- sourcestring
- email, chat or api. Send api — it is what makes the ticket recognisable as yours.
- external_refstring
- Your identifier for the same thing: an order number, an incident id, a row in your database.
- metadataobject
- Anything worth seeing beside the ticket. Shown to the agent as it arrives.
- tag_idsuuid[]
- Tags to apply, by id.
You get the whole ticket back, abridged here. It is already assigned: a new ticket goes to the project's default assignee whichever channel it came from, and they are notified exactly as a human assignment would notify them.
{
"id": "0f5d2a1c-…",
"reference": "K7X2M9BQTZ",
"subject": "Signup failed",
"status": "open",
"priority": "normal",
"source": "api",
"customer": { "id": "…", "email": "ada@example.com", "name": "Ada Lovelace" },
"assignee": { "id": "…", "name": "Grace Hopper" },
"message_count": 1,
"created_at": "2026-08-30T09:12:44Z"
}Answering one
Post to the ticket's messages. kind: "reply" goes to the customer; kind: "note" is an internal note only your team sees.
curl -X POST https://api.helpwing.app/api/v1/projects/$HELPWING_PROJECT_ID/tickets/<ticket_id>/messages/ \
-H "Authorization: Api-Key $HELPWING_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"kind": "reply",
"body_text": "Thanks for the report — deploying a fix now."
}'A reply comes back with a delivery_status of queued, or not_applicable when no email is owed — the customer has no address, they are reading the chat widget and the reply reaches them there, or the project has customer email switched off. The ticket's reply_destination says which of those applies before you post.
Attachments belong in the same request, as multipart with one files part per file. Delivery is queued the moment the request commits, so a file uploaded a round trip later is on the ticket but missing from the email that already left.
curl -X POST https://api.helpwing.app/api/v1/projects/$HELPWING_PROJECT_ID/tickets/<ticket_id>/messages/ \
-H "Authorization: Api-Key $HELPWING_API_KEY" \
-F kind=reply \
-F body_text="The log you asked for is attached." \
-F files=@server.logMoving it along
Status, priority, assignee and tags each have an endpoint of their own rather than being fields you PATCH — each one records the change in the ticket's activity timeline and fires the matching webhook.
curl -X POST https://api.helpwing.app/api/v1/projects/$HELPWING_PROJECT_ID/tickets/<ticket_id>/status/ \
-H "Authorization: Api-Key $HELPWING_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"status": "resolved"}'Webhooks
Polling a ticket list to notice a reply is work you should not have to do. Register an HTTPS endpoint under Developers → Webhooks, choose the events it cares about, and Helpwing posts each one to you as it happens. The endpoint has to be publicly reachable — an address that resolves inside a private network is refused rather than called.
- ticket.created
- A ticket arrives — by email, from the widget, or from this API.
- ticket.updated
- Its priority, tags or other fields changed.
- ticket.assigned
- It was assigned to a member, or unassigned.
- ticket.resolved
- It was marked resolved.
- message.created
- A customer message, an agent reply or an internal note was added.
- customer.created
- A customer record was created for the first time.
- email.delivered
- A reply was accepted by the receiving mail server.
- email.bounced
- A reply could not be delivered.
Every event is the same envelope, with the object it concerns under data. The delivery id also arrives as a Helpwing-Delivery header, which is what to store if you want retries to be idempotent.
{
"id": "evt_…",
"type": "ticket.created",
"created_at": "2026-08-30T09:12:44Z",
"project_id": "…",
"data": {
"object": "ticket",
"ticket": { "id": "…", "reference": "K7X2M9BQTZ", "status": "open", … }
}
}Check the signature
Your endpoint is a public URL, so anything can post to it. Each delivery carries Helpwing-Signature: t=<timestamp>,v1=<hex>, an HMAC-SHA256 of "<timestamp>.<body>" keyed with the endpoint's signing secret. Recompute it over the raw bytes you were sent, compare in constant time, and reject anything that fails — before you read the payload.
import { createHmac, timingSafeEqual } from 'node:crypto'
// The raw request body, not a re-serialised object: the digest covers the
// exact bytes that were sent.
export function verifyHelpwingSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map(part => part.split('=')))
// Reject replays. The timestamp is signed too, so it cannot be edited.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}Retries
Answer with any 2xx and the delivery is done. Anything else — a timeout, a 500, a redirect — is retried up to six times with a widening gap, from thirty seconds out to twelve hours, so an endpoint that is down for an afternoon still gets the event. Every attempt, with its request and response, is kept in the delivery log beside the endpoint. An endpoint that fails fifteen times in a row is disabled and stops being called until you turn it back on.
Because a retried delivery is the same event twice, handlers should be idempotent: key on id and ignore one you have already processed.
Every endpoint
The sections above are the parts worth explaining. The rest is the API reference: every operation a key can call, with its parameters, its body and the shape of what it returns — and the same document as an OpenAPI file, if you would rather generate a client than read one. It is generated from the API itself, so it describes what the endpoints do today rather than what they did when somebody last wrote about them.
Signed in already? Developers → Overview prints the first request on this page with your own project id already in it, and Developers → Webhooks can send a test event to your endpoint while you are still writing the handler.
NextAPI reference →Every endpoint a key can call, with its parameters, its body and what it returns.