The API
Read and work your tickets from your own code: list them, read one, reply, add a private note, change it, and open a new one for a customer. It's on Pro and higher.
Want an AI assistant to do this instead? See Connect your own AI. Want your code told when something happens? Webhooks are below.
Your key
Make a key under Settings → API and pick what it may do. Send it with every request:
Authorization: Bearer hpk_…A key belongs to one workspace and acts as its owner. Keep it on your server: anyone who has it can read your customers' messages.
| Permission | Lets the key |
|---|---|
workspace:read | Say who it is, and list your sites |
tickets:read | List and read tickets |
tickets:write | Reply, add a private note, change a ticket, open a ticket |
Everything is JSON over https://help.deltabreen.eu. A key may make 120 requests a minute.
When we say no
A refusal is always {"error": "<code>", "message": "<a sentence>"}. The code stays the same; the sentence may change.
| Status | Code | What happened |
|---|---|---|
| 400 | bad_body and others | Something in what you sent is wrong. The sentence says what |
| 401 | unauthorized | No key, or one we don't know |
| 402 | api_requires_pro | Your plan doesn't have the API. The key works again when the plan does |
| 403 | missing_scope | The key wasn't given that permission |
| 404 | not_found | No such ticket or site in your workspace |
| 409 | product_paused, ticket_is_merged | It can't be done to this one right now |
| 429 | rate_limited | Too many requests. Retry-After says how many seconds to wait |
A key we don't know in a body is refused, not ignored, so a typo never sends a private note to a customer.
Who am I
GET /api/v1/whoami
Works on every plan. It answers the workspace, its plan (tier) and the key's permissions (scopes).
curl https://help.deltabreen.eu/api/v1/whoami -H "Authorization: Bearer hpk_…"Sites
GET /api/v1/products
Your sites, each with its id and name (the API calls a site a product). You need a site's id to open a ticket in it.
curl https://help.deltabreen.eu/api/v1/products -H "Authorization: Bearer hpk_…"Tickets
GET /api/v1/tickets
A page of thirty tickets, newest change first.
| Query | Means |
|---|---|
view | open (the default), on_hold, pending, closed, all, spam or due |
product | Only this product's, by id |
assignee | none, me (the owner), someone (anyone on the team), or a teammate's id |
priority | low, normal, high or urgent |
tag | Only tickets with this tag |
q | Words to find in the subject or any message, or a ticket's number |
field | key:value, for a form field with a fixed set of answers |
customer | Every ticket from one person, by their id |
status | Only tickets in one of your own statuses, by its id (Lite and higher) |
team | A team's queue by its id, mine for your teams', or none for no team's (Pro and higher) |
company | Only tickets from one company's people, by its id (Pro and higher) |
tfield | key:value, for a team field (Lite and higher) |
mode | live (the default) or test |
sort | recent (the default) or priority |
before | The next of the page before |
curl "https://help.deltabreen.eu/api/v1/tickets?view=open&priority=urgent" -H "Authorization: Bearer hpk_…"{"tickets":[{"id":"6f1c…","number":1042,"subject":"Where is my order?","status":"open","mode":"live",
"priority":"urgent","tags":["refund"],"product_id":"…","product_name":"Acme shop",
"assignee_user_id":null,"assignee_email":null,"assignee_name":null,"external_id":"42","preview":"Order A-100 never came.",
"status_id":null,"status_name":null,"team_id":"…","team_name":"Billing","company_id":null,"company_name":null,
"created_at":"2026-10-13T09:10:00Z","updated_at":"2026-10-13T09:12:00Z"}],
"next":"2026-10-13T09:12:00.000000Z~6f1c…",
"counts":{"open":12,"on_hold":2,"pending":4,"closed":230}}next is empty on the last page. status is open (waiting on you), on_hold (waiting on someone else), pending (waiting on the customer) or closed; status_name is the name of one of your own statuses when the ticket is in one, which is always of the kind status says. team_name is the team it's with, and company_name the company its customer is with. external_id is the id your own site signed for a logged-in user; a guest has none. assignee_name is the name the person it is assigned to gave themselves under Settings → Team, empty until they give one.
GET /api/v1/tickets/{id}
One ticket with its whole thread. On top of what the list shows:
| Field | Holds |
|---|---|
messages | Each with seq, author_kind (end_user or agent), author_email and author_name (a teammate's, empty for the customer), visibility (public, or internal for a private note), body, created_at, mail (what became of the email that carried a reply) and sent_by (api when a key sent it, ai when a person's AI assistant did, empty from the portal) |
requester | The customer: id, email, external_id, verified, email_confirmed, email_stopped |
fields | What they answered in your form's own fields |
closed_at, purge_after, legal_hold | When it closed, when auto-delete removes it, and whether you're keeping it |
team_values | Your team fields on it, by key. Only your team sees them |
issues | The GitHub, Linear or Jira issues made from it or linked to it: kind, key, url, title |
curl https://help.deltabreen.eu/api/v1/tickets/6f1c… -H "Authorization: Bearer hpk_…"POST /api/v1/tickets
Open a ticket for a customer, from your contact form, your back office or another tool.
| Body | Means |
|---|---|
product_id | The product it belongs to. Needed |
email | Who it's from. Needed |
body | Their message. Needed |
subject | Left out, it's the start of the message |
fields | Answers to your form's own fields, by key: {"order_id": "A-100"} |
submission_id | Your own name for this ticket: 16 to 64 letters, digits, - or _. Send the same one again and you get the same ticket back, not a second one |
test | true makes a test ticket: nobody is emailed and it's removed after a week |
curl https://help.deltabreen.eu/api/v1/tickets -H "Authorization: Bearer hpk_…" \
-H "Content-Type: application/json" \
-d '{"product_id":"…","email":"[email protected]","subject":"Refund","body":"Order A-100 never came.","submission_id":"refund-order-A-100"}'{"id":"6f1c…","number":1043,"created":true}- The customer is always a guest. A key can't say someone is a logged-in user of your site: only the token your site signs does that, so a ticket opened here never shows the side panel or buttons.
- The first email they get asks them to confirm their address. Your replies are emailed once they have.
- The email names your site's verified domain, or its verified support address (Settings on the site) when it has no domain. With neither, nothing is emailed until one is verified.
- It counts as one of your plan's tickets for the month.
POST /api/v1/tickets/{id}/messages
Reply to the customer, or add a private note for your team.
| Body | Means |
|---|---|
body | The text. Needed |
visibility | public for a reply (the default), internal for a private note. "internal": true means the same |
status | Where the ticket goes next. Left out, a reply makes it pending, and one on_hold stays on hold |
curl https://help.deltabreen.eu/api/v1/tickets/6f1c…/messages -H "Authorization: Bearer hpk_…" \
-H "Content-Type: application/json" \
-d '{"body":"It ships tomorrow.","status":"closed"}'It answers {"message": {…}}, the message as the ticket now holds it.
PATCH /api/v1/tickets/{id}
Change a ticket. Send only what changes; it answers the ticket as it is afterwards.
| Body | Means |
|---|---|
status | open, on_hold, pending or closed |
status_id | One of your own statuses, by its id. It moves the ticket to that status's kind |
assignee_user_id | A teammate's id, or null for nobody |
team_id | A team's id, or null for no team |
team_values | Team fields by key, as {"refund": "12.50"}: only those sent change, and null clears one |
priority | low, normal, high or urgent |
tags | All of the ticket's tags. This replaces the list |
spam | true or false |
legal_hold | true keeps the ticket past auto-delete (Business) |
seen_updated_at | Optional. The ticket's updated_at as you last read it: if the ticket has changed since, a status or assignee_user_id sent with it changes nothing and the answer is 409 ticket_changed |
curl -X PATCH https://help.deltabreen.eu/api/v1/tickets/6f1c… -H "Authorization: Bearer hpk_…" \
-H "Content-Type: application/json" \
-d '{"priority":"urgent","tags":["refund"]}'Webhooks
A webhook tells your code when something happens on a ticket, so it doesn't have to keep asking. Add one under Settings → Webhooks and choose what it's sent. Pro has 3 webhooks, Business 10. Only an owner or an admin can add one, and only in the portal.
You can send to one of four places:
- Your URL. A URL on your own server. We send it nothing until it passes the connection test below.
- Zapier. Paste the URL of a "Webhooks by Zapier" Catch Hook.
- Make. Paste the URL of a "Custom webhook".
- n8n Cloud. Paste the production URL of a Webhook node set to POST. A self-hosted n8n is your URL: see the connection test below.
We never show a saved URL again. To change it, give the webhook a new one.
What we send
POST <your URL>
Content-Type: application/json
User-Agent: DeltaBreen-Help/1 (+https://help.deltabreen.eu/docs/integrations)
X-DeltaBreen-Request-Id: evt_…
X-DeltaBreen-Signature: t=<unix seconds>,v1=<hex>{"id":"evt_8841","type":"ticket.customer_replied","at":"2026-10-13T09:12:00Z","workspace":"acme",
"ticket":{"id":"6f1c…","number":1042,"mode":"live","status":"open","priority":"high","tags":["refund"],
"subject":"Where is my order?","product":{"id":"…","name":"Acme shop"},
"assignee":{"id":"…","email":"[email protected]"},"fields":[],"own_status":null,"team":{"id":"…","name":"Billing"},
"company":{"id":"…","name":"Acme Ltd"},"team_fields":{"refund":"12.50"},"created_at":"2026-10-13T09:10:00Z",
"url":"https://help.deltabreen.eu/app/w/acme/tickets/6f1c…"},
"message":{"id":"…","from":"customer","visibility":"public","body":"Order A-100 never came.","at":"2026-10-13T09:12:00Z"},
"customer":{"id":"…","email":"[email protected]","external_id":"42","verified":true}}type | Sent when | Also carries |
|---|---|---|
ticket.created | A customer opens a ticket | message, their first |
ticket.customer_replied | The customer replies | message |
ticket.agent_replied | Your team replies | message |
ticket.note_added | Someone adds a private note | message, with visibility internal |
ticket.status_changed | A ticket is opened, put on hold, set waiting or closed, or put in one of your own statuses | change: from, to, and own (its name) for your own |
ticket.assigned | A ticket is given to someone, or to nobody | change: to |
ticket.priority_changed | The priority changes | change: from, to |
ticket.tags_changed | Tags are added or removed | change: added, removed |
ticket.team_changed | A ticket is given to a team, or to none | change: from, to, to_name |
ticket.late | A reply is late for your reply times | |
ticket.rated | The customer rates a closed ticket | rating: stars (1 to 5), words |
ping | You press Test connection | Nothing: no ticket, no customer |
ticketis the ticket as it is when we send, which may be a moment after the event.atis when it happened.customer.external_idis the id your site signed for a logged-in user. A guest has none, andverifiedisfalse.ticket.statusisopen,on_hold(waiting on someone else),pendingorclosed;own_statusis one of your own statuses when the ticket is in one.team,companyandteam_fieldsare null or empty when there are none.- A private note is only sent to a webhook that asks for
ticket.note_added. - Test tickets are only sent to a webhook with "Send test tickets too" on. Their
ticket.modeistest. Spam is never sent. - A very long message is cut to fit, and the body then has
"cut": true. Read the whole ticket with the API.
Check it's from us
Every request is signed with the webhook's secret, the same way as an integration's. See Check the signature first: check the signature against the raw body before you parse it, and turn away a t more than five minutes old.
You get the secret once, when you add the webhook. "Get a new secret" makes another; for a day we sign with both, so you can change your server without missing anything.
The connection test
Before your own URL is sent any event, it must prove it holds the secret. Press Test connection and we send:
{"id":"req_…","type":"handshake","mode":"live","product":"","sent_at":"2026-10-13T09:00:00Z","challenge":"…"}Answer with {"challenge_response": "<hex HMAC-SHA256(secret, challenge)>"}. It's the same test an integration passes, so one handler can serve both. Change the URL and we run the test again before sending to the new one.
Zapier, Make and n8n Cloud can't answer the test, so we don't ask them. Their URL must be one of theirs.
A self-hosted n8n can answer it. In the workflow, set the Webhook node to respond with "Respond to Webhook". Then, when the body's type is handshake, a Crypto node makes an HMAC (SHA256, hex) of challenge with the webhook's secret. Answer {"challenge_response": "<that hex>"}. Send any other request on through the rest of the workflow.
When your URL is down
- Answer with any
2xxwithin five seconds and the event is done. We don't read what you answer. - Anything else and we try again: after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. Then we give up on that event.
- An event can arrive twice, or out of order after a retry. Use
idto act on each one once. - If your URL answers
404or410, or we give up on twenty events in a row, we turn the webhook off and email the workspace's owner, once. Turn it on again under Settings → Webhooks, where you can also see what was sent in the last three days and the events we gave up on in the last two weeks, and send one of those again.