Integrations
An integration is one URL on your server. We send it a signed request when an agent opens a ticket, so we can show who the customer is, and when an agent presses a button, so you can do something in your system. We show your answer to the agent. We don't store it or log it.
Want to be told when a ticket arrives or changes instead? That's a webhook: see The API. It's signed the same way and passes the same connection test.
Want to start from working code? Here's a whole endpoint in one file, with tests: examples/customer-backend/server.mjs.
The request
POST <your integration's URL>
Content-Type: application/json
User-Agent: DeltaBreen-Help/1 (+https://help.deltabreen.eu/docs/integrations)
X-DeltaBreen-Request-Id: req_…
X-DeltaBreen-Signature: t=<unix seconds>,v1=<hex>- It's always a
POSTto the one URL you gave us, with this body. Ids are in the body, not the URL. - Your URL must be
httpson port 443, on a public address. We don't follow redirects. - If you set a header of your own for the URL, we send it too, next to ours.
typeishandshake,hydrateoraction. A fourth,import, is sent only to our WordPress plugin, to bring a site's old tickets over, and never to a URL of your own.
{"id":"req_…","type":"hydrate","mode":"live","product":"prd_…","sent_at":"2026-10-12T12:00:00Z",
"user":{"id":"42"},"ticket":{"id":"…","number":1042},"agent":{"id":"…"}}type | Carries | Answer with |
|---|---|---|
handshake | challenge | {"challenge_response": "<hex HMAC-SHA256(secret, challenge)>"} |
hydrate | user, agent, usually ticket | Any JSON object: whatever you want shown next to the ticket |
action | user, agent, action: {key, params, idempotency_key} | {"ok": true}. You can add reference (we keep it in the audit log, up to 64 characters) and message |
user.id is the id your own server signed for that person, so it can be trusted. mode is live or test.
Check the signature first
v1 is HMAC-SHA256, in lowercase hex, over "<t>." + the raw request body, using the integration's secret as the key. Check it against the bytes you received, before you parse them. Turn away any request whose t is more than 300 seconds from your clock. After you get a new secret, the header has two v1 values for a day, one for each secret. Accept either.
Test your code against these: docs/signature-vectors.json.
// Node. rawBody is the exact bytes received.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(header, rawBody, secret) {
const parts = (header ?? '').split(',').map((p) => p.trim())
const t = parts.find((p) => p.startsWith('t='))?.slice(2)
if (!t || !/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const want = Buffer.from(createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'))
return parts.filter((p) => p.startsWith('v1=')).map((p) => Buffer.from(p.slice(3)))
.some((got) => got.length === want.length && timingSafeEqual(got, want))
}The connection test
Before we send your URL anything about a customer, it has to show it holds the integration's secret. We send a handshake with a random challenge, and your URL answers with the HMAC of that challenge, using the secret as the key. Sending the challenge straight back doesn't count. This is what Test connection does in the portal, and it's why an integration can only point at a system that has your secret, never at someone else's API.
Buttons
A button sends a request to the same URL as everything else, with type set to action and the button's key. You add buttons on the integration's Buttons tab in the portal.
{"id":"req_…","type":"action","mode":"live","product":"prd_…","sent_at":"2026-10-12T12:00:00Z",
"user":{"id":"42"},"ticket":{"id":"…","number":1042},"agent":{"id":"…"},
"action":{"key":"cancel_subscription","params":{"reason":"Asked by email"},"idempotency_key":"…"}}Do the work, then answer {"ok": true}, or {"ok": false} if you won't do it. Add a message and we show it to the agent. Either way we add a private note to the ticket.
An agent has to type a word before a button runs. idempotency_key is the same for every retry of one press, so do the thing once, and answer a repeat with the first result. We never retry a press ourselves.
Limits
| Time to answer | 8 s for hydrate and handshake, 15 s for action |
| Answer size | 256 KB |
| Answer type | application/json, UTF-8 |
| Redirects | Not followed |
| Our address | Requests come from the address shown on your integration's Connection tab, so your firewall can allow it |
Stripe and Shopify, without a URL of your own
On Pro and higher you can show what Stripe or your Shopify shop knows about a customer without writing anything. On the Integrations page press Add Stripe or Add Shopify:
| You give | We read | |
|---|---|---|
| Stripe | A restricted key (rk_live_…), with read access to customers, subscriptions and charges | The customer with that email, their subscriptions and their last charges |
| Shopify | Your shop's ….myshopify.com address and an Admin API access token from a custom app that may read customers and orders | The customer with that email and their last orders |
- We look a customer up only by an email their site signed or that they confirmed. A guest who only typed an address is never looked up.
- The key is tried before it is kept, then kept sealed and never shown again. Stripe's secret keys (
sk_…) are refused: a restricted key can only read. - What Stripe or Shopify answers is shown to the agent and kept nowhere, as your own URL's answer is.
- You choose what the side panel shows on its Side panel tab, as with any integration. They have no buttons.