Conventions
Every path below is under https://api.harkbell.com/api/v1, over HTTPS.
Requests and answers are JSON, and every key in them is snake_case. In a request, a field sent as null counts as one left out.
Every time Harkbell sends whose name ends in _at is an ISO-8601 instant in UTC, such as 2026-10-01T14:03:12.000Z. The business’s own timezone is in business.timezone, on every delivery and in the answer to GET /api/v1/me, for showing a time the way the studio reads its own diary.
The one exception is a booking request’s preferred_windows: what the caller asked for, on the studio’s own calendar and clock. Each window’s date is a day, such as 2026-10-04, and its from, to and at are times of day, such as 10:00, in business.timezone — not instants, and not UTC. at is the exact time the caller named, and null when they named none.
Every _at time in a booking, a callback, a call, a booking request or a contact comes with a twin whose name ends in _at_local, such as start_at_local: the same moment on the business’s own clock, to the second, with the offset that clock had at that moment, such as 2026-10-03T10:00:00-04:00. Map that one wherever a person reads the time: a spreadsheet, a message, a calendar entry’s title. The twin is null when its _at time is, and null when the business’s timezone is one Harkbell cannot read, rather than a time with a guessed offset. The twins come last, after url, so the fields before them are where they always were.
Every error answers with a JSON body of { error, code }: error is a sentence you can show a person as it stands, and code is a stable name, such as UNKNOWN_EVENT, to branch on.
| Status | What it means |
|---|
400 | The request was refused, and the error sentence says why: a body that is not valid JSON (INVALID_JSON), a field longer than it may be (FIELD_TOO_LONG, with the sentence naming the field), a contact with no phone number or email, an address that cannot receive webhooks, or an event Harkbell does not send (UNKNOWN_EVENT). |
401 | The key is missing (API_KEY_MISSING), or it is malformed, unknown or revoked (API_KEY_INVALID). Those last three answer alike, so a revoked key reads the same as a mistyped one. |
404 | There is no such route (NOT_FOUND). An event Harkbell does not send is a 400, not a 404. |
409 | The request was refused, and the error sentence says why: the business is at its subscription limit (SUBSCRIPTION_LIMIT), or the person was erased from its contacts (CONTACT_ERASED). |
422 | The body is not the shape the route takes: a field of the wrong type, such as urgent sent as the string "true". The answer is { "error": "Invalid request body.", "code": "VALIDATION" }. Types are checked before the key, so a request wrong in both is told VALIDATION first. |
429 | Too many requests (RATE_LIMITED): more than 120 a minute on one key, more than 30 failed key checks a minute from one address, or more than 60 API callbacks an hour for one business. Wait a minute and try again, or up to an hour for callbacks. |
Endpoints
| Method | Path | What it does |
|---|
GET | /api/v1/me | Returns the business a key belongs to and the key itself, which makes it the request to test a key with. |
GET | /api/v1/events | Lists the events Harkbell sends, with the key each one's subject arrives under. |
POST | /api/v1/hooks | Subscribes an address to one event, and returns the secret that signs every delivery to it. |
GET | /api/v1/hooks | Lists every subscription the business holds, newest first, without their secrets. |
DELETE | /api/v1/hooks/:id | Removes a subscription, so nothing more is posted to its address. |
GET | /api/v1/events/:event/recent | Returns the most recent events of one kind, built from the business's own records. |
POST | /api/v1/contacts | Creates a contact, or fills in the details missing from the one with the same phone number or email. |
GET | /api/v1/contacts | Finds contacts by phone number or email. |
GET | /api/v1/contacts/:id | Reads one contact by its id. |
POST | /api/v1/callbacks | Adds a callback to the business's callbacks queue, for someone on the team to ring back. |
GET /api/v1/me
Returns the business a key belongs to and the key itself, which makes it the request to test a key with.
Answers 200
{
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"slug": "willow-and-co",
"timezone": "America/New_York"
},
"api_key": {
"id": "e3f4a5b6-c7d8-4e9f-8a0b-1c2d3e4f5a6b",
"name": "Zapier",
"prefix": "hk_live_7Qx2"
}
}
GET /api/v1/events
Lists the events Harkbell sends, with the key each one's subject arrives under.
- The ping from Send test event is not listed, because nothing can subscribe to it.
Answers 200
{
"events": [
{
"event": "booking.created",
"subject": "booking",
"description": "An appointment was added to the diary — by the agent on a call, by the team, from a confirmed request, or by a connected booking system. Also sent when a cancelled appointment comes back."
},
{
"event": "booking.moved",
"subject": "booking",
"description": "An appointment moved to a different time."
},
{
"event": "booking.cancelled",
"subject": "booking",
"description": "An appointment was cancelled."
},
{
"event": "callback.created",
"subject": "callback",
"description": "Somebody needs ringing back: a caller left a message, a call ended with a follow-up promised, or the team added one at the desk."
},
{
"event": "call.completed",
"subject": "call",
"description": "A phone call the agent answered or placed has ended and been filed."
},
{
"event": "booking_request.created",
"subject": "booking_request",
"description": "A caller asked for a time that somebody has to confirm or decline."
},
{
"event": "contact.created",
"subject": "contact",
"description": "A new contact with a phone number or an email address was added — from a call, a text, a booking or the desk."
},
{
"event": "contact.merged",
"subject": "contact",
"description": "Two contacts turned out to be one person and were merged. The body is the contact that remains; merged_contact_id is the one that went, whose id still works on GET /api/v1/contacts/:id."
}
]
}
POST /api/v1/hooks
Subscribes an address to one event, and returns the secret that signs every delivery to it.
- The secret is returned here and nowhere else, so keep it with the subscription's id.
- Subscribing the same address to the same event again answers 200 with the subscription that already exists and the same secret, and switches it back on if Harkbell had switched it off.
- A business can hold 25 subscriptions, and one more answers 409
SUBSCRIPTION_LIMIT. - An address must be public and on port 443 or 80, or the answer is 400
INVALID_WEBHOOK_URL. An event Harkbell does not send, ping included, answers 400 UNKNOWN_EVENT.
Request
{
"target_url": "https://hooks.zapier.com/hooks/standard/1234567/0a1b2c3d4e5f/",
"event": "booking.created"
}
Answers 201
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"event": "booking.created",
"target_url": "https://hooks.zapier.com/hooks/standard/1234567/0a1b2c3d4e5f/",
"secret": "whsec_0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c",
"created_at": "2026-10-01T12:30:00.000Z"
}
GET /api/v1/hooks
Lists every subscription the business holds, newest first, without their secrets.
- A subscription Harkbell has switched off has
disabled_at set, and disabled_reason says why.
Answers 200
{
"hooks": [
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"event": "booking.created",
"target_url": "https://hooks.zapier.com/hooks/standard/1234567/0a1b2c3d4e5f/",
"created_at": "2026-10-01T12:30:00.000Z",
"last_delivered_at": "2026-10-01T14:03:13.000Z",
"last_failure_at": null,
"disabled_at": null,
"disabled_reason": null
}
]
}
DELETE /api/v1/hooks/:id
Removes a subscription, so nothing more is posted to its address.
- The answer is
deleted: false for an id that is not this business's or no longer exists, so retrying a removal is safe.
Answers 200
{
"deleted": true
}
GET /api/v1/events/:event/recent
Returns the most recent events of one kind, built from the business's own records.
- Each item is in exactly the shape a delivery has, so a field mapped from one is there in the next real delivery.
- The answer is an empty array when the business has none of that event yet.
- Callbacks and contacts added through the API are left out, as they are from deliveries.
- On
booking.moved, previous_start_at is null here, because the time a booking moved from is known only at the moment it moves. - On
contact.merged, each item is one merge that still stands, onto a contact that still exists, with merged_contact_id filled in.
Query parameters
limit: How many to return, from 1 to 10. It is 3 when it is left out or is not a number.
Answers 200
[
{
"id": "0f5e6a8c-3b1d-4c2e-9a7f-6d4b2e8c1a37",
"event": "booking.created",
"occurred_at": "2026-10-01T14:03:12.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"booking": {
"id": "5d2c9b7e-1f4a-4e6b-8c3d-9a0e2f1b7c64",
"title": "Balayage",
"service": "Balayage",
"resource": "Maya",
"start_at": "2026-10-03T14:00:00.000Z",
"end_at": "2026-10-03T16:30:00.000Z",
"duration_minutes": 150,
"status": "booked",
"source": "agent",
"customer_name": "Sam Rivera",
"customer_phone": "+14155550134",
"customer_email": "sam.rivera@example.com",
"price": "$180",
"url": "https://harkbell.com/app/willow-and-co/bookings?date=2026-10-03",
"start_at_local": "2026-10-03T10:00:00-04:00",
"end_at_local": "2026-10-03T12:30:00-04:00"
}
}
]
POST /api/v1/contacts
Creates a contact, or fills in the details missing from the one with the same phone number or email.
- It matches an existing contact on the phone number or the email address. A match answers 200 with
created: false, and only the details the contact was missing are filled in: nothing already on it is overwritten. - A new contact answers 201 with
created: true, and its source is api. - A phone number or an email address is required, and a phone number needs seven to fifteen digits.
- A phone number is saved with its country code, like +442079460958, when it was sent with one or is a real number in the business's own country; anything else is saved exactly as it was sent.
- A person erased from the contacts is never recreated: the answer is 409
CONTACT_ERASED. - It never sends
contact.created or contact.merged.
Request
{
"name": "Priya Shah",
"phone": "+14155550162",
"email": "priya.shah@example.com"
}
Answers 201
{
"contact": {
"id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
"name": "Priya Shah",
"phone": "+14155550162",
"email": "priya.shah@example.com",
"tags": [],
"source": "api",
"created_at": "2026-10-01T18:12:03.000Z",
"url": "https://harkbell.com/app/willow-and-co/contacts?contact=2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
"created_at_local": "2026-10-01T14:12:03-04:00"
},
"created": true
}
GET /api/v1/contacts
Finds contacts by phone number or email.
- A contact matching either the phone number or the email address comes back, at most 5 of them, newest first.
- An erased person is never returned.
- With neither a phone number nor an email address the answer is 400
SEARCH_NEEDS_PHONE_OR_EMAIL.
Query parameters
phone: A phone number, matched on its digits whatever its formatting.email: An email address, matched whatever its capitals.
Answers 200
{
"contacts": [
{
"id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
"name": "Priya Shah",
"phone": "+14155550162",
"email": "priya.shah@example.com",
"tags": [],
"source": "api",
"created_at": "2026-10-01T18:12:03.000Z",
"url": "https://harkbell.com/app/willow-and-co/contacts?contact=2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
"created_at_local": "2026-10-01T14:12:03-04:00"
}
]
}
GET /api/v1/contacts/:id
Reads one contact by its id.
- When two contacts turn out to be the same person, Harkbell merges them into one. The id of the contact that was merged away keeps working here: it answers with the contact it became, and mergedFrom names the id you asked for. mergedFrom is null otherwise.
- An unknown or erased id answers 404
CONTACT_NOT_FOUND.
Answers 200
{
"contact": {
"id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
"name": "Priya Shah",
"phone": "+14155550162",
"email": "priya.shah@example.com",
"tags": [],
"source": "api",
"created_at": "2026-10-01T18:12:03.000Z",
"url": "https://harkbell.com/app/willow-and-co/contacts?contact=2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
"created_at_local": "2026-10-01T14:12:03-04:00"
},
"mergedFrom": null
}
POST /api/v1/callbacks
Adds a callback to the business's callbacks queue, for someone on the team to ring back.
- It lands in the callbacks queue with source api, and the team is told about it as it is about any other callback.
urgent: true marks it urgent. - name and reason are required, with a phone number or an email address to ring back on. When both are given, the phone number is the one used.
due_at is the time the callback is promised by, in ISO-8601 with Z or an offset, such as 2026-10-12T15:00:00-04:00, and is kept exactly as sent. A due_at with neither Z nor an offset is refused with 400 INVALID_PROMISED_AT, in a sentence naming the missing offset, rather than guessed at, because it could be a time in any timezone. Without a due_at, Harkbell promises what its agent promises on a call: within the next two hours, or within the next fifteen minutes when urgent, counted from the business's next opening in its own timezone when it is closed or would close first.- It never sends
callback.created, and a business can add 60 callbacks an hour this way.
Request
{
"name": "Alex Kim",
"email": "alex.kim@example.com",
"reason": "Asked on the website about bridal packages for a party of five.",
"urgent": false
}
Answers 201
{
"callback": {
"id": "d5e6f7a8-9b0c-4d1e-8f2a-3b4c5d6e7f80",
"contact_name": "Alex Kim",
"contact_channel": "alex.kim@example.com",
"contact_id": "e6f7a8b9-0c1d-4e2f-9a3b-4c5d6e7f8091",
"reason": "Asked on the website about bridal packages for a party of five.",
"urgent": false,
"source": "api",
"status": "open",
"promised_at": "2026-10-01T20:00:00.000Z",
"created_at": "2026-10-01T18:00:00.000Z",
"closed_at": null,
"run_id": null,
"url": "https://harkbell.com/app/willow-and-co/callbacks?callback=d5e6f7a8-9b0c-4d1e-8f2a-3b4c5d6e7f80",
"promised_at_local": "2026-10-01T16:00:00-04:00",
"created_at_local": "2026-10-01T14:00:00-04:00",
"closed_at_local": null
}
}
Events
| Event | What it means | Subject key |
|---|
booking.created | An appointment was added to the diary — by the agent on a call, by the team, from a confirmed request, or by a connected booking system. Also sent when a cancelled appointment comes back. | booking |
booking.moved | An appointment moved to a different time. | booking |
booking.cancelled | An appointment was cancelled. | booking |
callback.created | Somebody needs ringing back: a caller left a message, a call ended with a follow-up promised, or the team added one at the desk. | callback |
call.completed | A phone call the agent answered or placed has ended and been filed. | call |
booking_request.created | A caller asked for a time that somebody has to confirm or decline. | booking_request |
contact.created | A new contact with a phone number or an email address was added — from a call, a text, a booking or the desk. | contact |
contact.merged | Two contacts turned out to be one person and were merged. The body is the contact that remains; merged_contact_id is the one that went, whose id still works on GET /api/v1/contacts/:id. | contact |
booking.created
Sent when a booking is made: by the receptionist on a call, at the desk, by confirming a booking request, or in a connected booking system, and when a cancelled booking comes back. It is not sent for the appointments already in a booking system's diary when that system is first connected.
{
"id": "0f5e6a8c-3b1d-4c2e-9a7f-6d4b2e8c1a37",
"event": "booking.created",
"occurred_at": "2026-10-01T14:03:12.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"booking": {
"id": "5d2c9b7e-1f4a-4e6b-8c3d-9a0e2f1b7c64",
"title": "Balayage",
"service": "Balayage",
"resource": "Maya",
"start_at": "2026-10-03T14:00:00.000Z",
"end_at": "2026-10-03T16:30:00.000Z",
"duration_minutes": 150,
"status": "booked",
"source": "agent",
"customer_name": "Sam Rivera",
"customer_phone": "+14155550134",
"customer_email": "sam.rivera@example.com",
"price": "$180",
"url": "https://harkbell.com/app/willow-and-co/bookings?date=2026-10-03",
"start_at_local": "2026-10-03T10:00:00-04:00",
"end_at_local": "2026-10-03T12:30:00-04:00"
}
}
booking.moved
Sent when a booking's start time changes, at the desk or in a connected booking system. The booking carries previous_start_at, the time it moved from, which is null in GET /api/v1/events/booking.moved/recent because only a live delivery knows it.
{
"id": "1a6f7b9d-4c2e-4d3f-8b8a-7e5c3f9d2b48",
"event": "booking.moved",
"occurred_at": "2026-10-02T13:15:40.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"booking": {
"id": "5d2c9b7e-1f4a-4e6b-8c3d-9a0e2f1b7c64",
"title": "Balayage",
"service": "Balayage",
"resource": "Maya",
"start_at": "2026-10-04T15:00:00.000Z",
"end_at": "2026-10-04T17:30:00.000Z",
"duration_minutes": 150,
"status": "booked",
"source": "agent",
"customer_name": "Sam Rivera",
"customer_phone": "+14155550134",
"customer_email": "sam.rivera@example.com",
"price": "$180",
"previous_start_at": "2026-10-03T14:00:00.000Z",
"url": "https://harkbell.com/app/willow-and-co/bookings?date=2026-10-04",
"start_at_local": "2026-10-04T11:00:00-04:00",
"end_at_local": "2026-10-04T13:30:00-04:00",
"previous_start_at_local": "2026-10-03T10:00:00-04:00"
}
}
booking.cancelled
Sent when a booking is cancelled, at the desk or in a connected booking system. A cancelled booking that comes back is sent as booking.created.
{
"id": "2b7a8c0e-5d3f-4e4a-9c9b-8f6d4a0e3c59",
"event": "booking.cancelled",
"occurred_at": "2026-10-02T21:40:05.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"booking": {
"id": "5d2c9b7e-1f4a-4e6b-8c3d-9a0e2f1b7c64",
"title": "Balayage",
"service": "Balayage",
"resource": "Maya",
"start_at": "2026-10-04T15:00:00.000Z",
"end_at": "2026-10-04T17:30:00.000Z",
"duration_minutes": 150,
"status": "cancelled",
"source": "agent",
"customer_name": "Sam Rivera",
"customer_phone": "+14155550134",
"customer_email": "sam.rivera@example.com",
"price": "$180",
"url": "https://harkbell.com/app/willow-and-co/bookings?date=2026-10-04",
"start_at_local": "2026-10-04T11:00:00-04:00",
"end_at_local": "2026-10-04T13:30:00-04:00"
}
}
callback.created
Sent when a caller asks to be rung back, when a call ends with a follow-up promised, or when the team adds a callback at the desk. A second message from the same call joins the callback already open and is not sent again, and a callback added through the API is never sent.
{
"id": "3c8b9d1f-6e4a-4f5b-8d0c-9a7e5b1f4d6a",
"event": "callback.created",
"occurred_at": "2026-10-01T15:22:31.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"callback": {
"id": "c4a1e9d2-7b3f-4a5c-8e6d-1f2b3c4d5e6f",
"contact_name": "Jordan Lee",
"contact_channel": "+14155550187",
"contact_id": "4b5c6d7e-8f9a-4b0c-8d1e-2f3a4b5c6d7e",
"reason": "Wants to know whether the studio can correct box-dyed hair before a wedding on the 18th.",
"urgent": false,
"source": "call",
"status": "open",
"promised_at": "2026-10-01T17:22:31.000Z",
"created_at": "2026-10-01T15:22:31.000Z",
"closed_at": null,
"run_id": 48227,
"url": "https://harkbell.com/app/willow-and-co/callbacks?callback=c4a1e9d2-7b3f-4a5c-8e6d-1f2b3c4d5e6f",
"promised_at_local": "2026-10-01T13:22:31-04:00",
"created_at_local": "2026-10-01T11:22:31-04:00",
"closed_at_local": null
}
}
call.completed
Sent once for each call the receptionist answers, on the phone or on the website, and each call it places, when the call reaches the dashboard. It carries who, when, how long and a short summary, with a link to read the rest in the dashboard. It is never sent for a text chat, for calls loaded later from history, or for a call that reaches the dashboard more than a day after it started.
{
"id": "4d9c0e2a-7f5b-4a6c-9e1d-0b8f6c2a5e7b",
"event": "call.completed",
"occurred_at": "2026-10-01T14:05:02.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"call": {
"id": "3b2a1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9",
"run_id": 48213,
"direction": "inbound",
"started_at": "2026-10-01T13:59:47.000Z",
"duration_seconds": 214,
"completed": true,
"disposition": "completed",
"summary": "Asked about balayage prices and booked Saturday at 10am with Maya.",
"caller_number": "+14155550134",
"contact_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"contact_name": "Sam Rivera",
"has_transcript": true,
"has_recording": true,
"url": "https://harkbell.com/app/willow-and-co/conversations?call=48213",
"started_at_local": "2026-10-01T09:59:47-04:00"
}
}
booking_request.created
Sent once for each booking request, when a caller asks for a time that somebody has to confirm or decline. Its preferred_windows are days and times of day on the studio's own clock, in the business's timezone, rather than instants in UTC. It never carries the one-tap link that confirms a request: that link books without logging in, so it never leaves Harkbell.
{
"id": "5e0d1f3b-8a6c-4b7d-8f2e-1c9a7d3b6f8c",
"event": "booking_request.created",
"occurred_at": "2026-10-01T18:10:44.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"booking_request": {
"id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"status": "pending",
"service": "Gel manicure",
"requested_resource": "",
"preferred_windows": [
{
"date": "2026-10-04",
"from": "10:00",
"to": "12:00",
"rank": 1,
"at": null
}
],
"client_name": "Priya Shah",
"client_phone": "+14155550162",
"client_email": "",
"notes": "First visit. Asked whether there is parking nearby.",
"contact_id": null,
"booking_id": null,
"expires_at": "2026-10-02T18:10:44.000Z",
"created_at": "2026-10-01T18:10:44.000Z",
"url": "https://harkbell.com/app/willow-and-co/requests",
"expires_at_local": "2026-10-02T14:10:44-04:00",
"created_at_local": "2026-10-01T14:10:44-04:00"
}
}
Sent when someone new with a phone number or an email address is added to the contacts: from a call, a text, a booking or the desk. It is never sent for an imported list, for a contact added through the API, or for a person who was erased.
{
"id": "6f1e2a4c-9b7d-4c8e-9a3f-2d0b8e4c7a9d",
"event": "contact.created",
"occurred_at": "2026-10-01T14:03:10.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"contact": {
"id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"name": "Sam Rivera",
"phone": "+14155550134",
"email": "sam.rivera@example.com",
"tags": [],
"source": "call",
"created_at": "2026-10-01T14:03:10.000Z",
"url": "https://harkbell.com/app/willow-and-co/contacts?contact=9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"created_at_local": "2026-10-01T10:03:10-04:00"
}
}
Sent when two contacts are merged into one: by the team, by the receptionist when a person gives a number or address another contact already holds, or when a phone number a call or a text proves turns up on two contacts. The contact is the one that remains, and merged_contact_id is the one that no longer exists. A GET /api/v1/contacts/:id for that old id answers with the contact that remains. It is never sent for anything added through the API, and undoing a merge sends nothing.
{
"id": "8b3c4d6e-1f9a-4e0b-8c5d-4f2a0b6e9c1f",
"event": "contact.merged",
"occurred_at": "2026-10-03T16:41:27.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
},
"contact": {
"id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"name": "Sam Rivera",
"phone": "+14155550134",
"email": "sam.rivera@example.com",
"tags": [],
"source": "call",
"created_at": "2026-10-01T14:03:10.000Z",
"merged_contact_id": "0d1e2f3a-4b5c-4d6e-9f7a-8b9c0d1e2f3a",
"url": "https://harkbell.com/app/willow-and-co/contacts?contact=9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"created_at_local": "2026-10-01T10:03:10-04:00"
}
}
ping
Send test event, under Webhook in Settings → Developers, posts a ping: an envelope with no subject, signed like every other delivery. It proves that an address answers and that a receiver checks signatures, and nothing can subscribe to it.
{
"id": "7a2f3b5d-0c8e-4d9f-8b4a-3e1c9f5d8b0e",
"event": "ping",
"occurred_at": "2026-10-01T12:00:00.000Z",
"business": {
"id": "8b0e6f3a-2c4d-4e5f-9a1b-3c5d7e9f1a2b",
"name": "Willow & Co",
"timezone": "America/New_York"
}
}
Receiving webhooks
There are two ways to have events posted to an address.
- The Webhook in Settings → Developers: one address, and a tick box for each event, signed with the Signing secret shown under it. An address set up before the tick boxes existed keeps receiving the three booking events it always did, until more are ticked.
POST /api/v1/hooks, once for each address and event, which is how Zapier subscribes. Each subscription has its own secret, returned when it is made, and DELETE /api/v1/hooks/:id ends it.
curl https://api.harkbell.com/api/v1/hooks \
-H "Authorization: Bearer $HARKBELL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"target_url":"https://example.com/harkbell","event":"booking.created"}'
Every delivery is a POST with a JSON body, and three headers:
harkbell-signature: when it was signed and the signature, as t=<unix seconds>,v1=<hex>.harkbell-event: the event’s name, the same as event in the body.user-agent: Harkbell-Webhooks/1.
Checking the signature
Make an HMAC-SHA256 of the timestamp, a full stop and the raw request body, keyed with the whole signing secret, whsec_ and all, and compare it with v1 in constant time. Use the body exactly as it arrived: parsed and serialised again, JSON is different bytes and never matches. Refuse a timestamp more than 5 minutes old, so a recorded delivery cannot be replayed later.
const crypto = require("node:crypto");
// rawBody: the request body exactly as it arrived, before any JSON parsing.
// header: the harkbell-signature header. secret: your whsec_ signing secret.
function verifyHarkbellSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(header || "").split(",").map((part) => part.trim().split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const given = Buffer.from(parts.v1, "hex");
const wanted = Buffer.from(expected, "hex");
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}
import hashlib
import hmac
import time
def verify_harkbell_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
<?php
function harkbell_verify_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_HARKBELL_SIGNATURE'] ?? '';
What a receiver can rely on
- Every delivery is signed: for the Webhook in your settings, with the Signing secret shown under it in Settings → Developers, and for a subscription, with the secret
POST /api/v1/hooks returned when it was made. - Answer with a 2xx within 10 seconds, and do any slow work after answering. A slower answer counts as a failure.
- Delivery is at least once, so the same event can arrive twice: dedupe on id.
- Order is not guaranteed, so use
occurred_at to tell which of two events came first. - Redirects are not followed, and a 3xx answer counts as a failure.
- A 5xx, a 429, a 408, a timeout or an address that cannot be reached is retried, with a pause that starts at 5 seconds and doubles up to 5 minutes: 12 attempts over about 30 minutes. Any other 4xx is recorded and not retried.
- After 3 failed attempts in a row to one address — a subscription's or the Webhook in your settings — Harkbell pauses deliveries to it, 30 seconds at first, doubling up to an hour, without using up the events' retries. So an address that keeps failing can wait longer than about 30 minutes for an event, and an event still waiting a day after it happened is given up on.
- A 410 from a subscription's address deletes the subscription, which is how REST hooks are meant to end. The Webhook in your settings records a 410 as a failure instead, and does not retry it.
- When Harkbell ends a subscription for you — it is removed under Connected automations in Settings → Developers, or its key is revoked — and the subscription's
target_url is a Zapier REST hook, it sends a DELETE to that address, Zapier's way for a service to say a trigger has gone, and Zapier pauses the Zap. Other addresses are not told: turn the scenario off where it lives. - A subscription whose address fails 15 delivery attempts in a row is switched off, and shows as Switched off under Connected automations in Settings → Developers. Subscribing the same address to the same event again, for example by turning the Zap or scenario off and on, switches it back on.
- The Webhook in your settings is never switched off automatically.
- The body is built when it is sent, so a retry describes the booking, callback or contact as it is now. One deleted by then, or a contact erased by then, is not sent at all.
- An erased person is never sent: a booking, callback, call or booking request about someone erased from the contacts is still sent, but with their name, phone number and email blank.
- Addresses must be public; https:// is strongly recommended, and only ports 443 and 80 are accepted. The address is checked again before every delivery.
- Writes through the API never raise webhook events, so a Zap that adds a contact or a callback cannot trigger itself.