# The Webkio API and webhooks

Connect your Webkio account to Zapier, Make, n8n or your own code: have new orders, bookings and
reservations sent to a URL the moment they happen, and let other tools add contacts, email subscribers
and blog post drafts, or look up your orders.

The API and webhooks are part of every plan with integrations (Starter and up).

For developers, [developer.webkio.com](https://developer.webkio.com) has the
[interactive API reference](https://developer.webkio.com/docs) - try any endpoint with your own
account, no key needed - and the [developer console](https://developer.webkio.com/console) for your
keys, webhooks, delivery log and recent requests. You are signed in there with your Webkio login.

## Create an API key

Go to **Settings > API & webhooks**, or the [developer console](https://developer.webkio.com/console),
and create a key. Name it after where you will use it
("Zapier", "Order sync script"), so you know which one to revoke later.

The key is shown **once**. Webkio keeps only a fingerprint of it, so if you lose it, revoke it and
create another. A key acts as you, with access to all your sites: keep it out of shared documents
and code repositories. Only the account owner can create keys.

Revoking a key stops it at once, and removes the webhooks it created.

## Making requests

Base URL: `https://api.webkio.com/v1`. Every endpoint is also in the
[API reference](https://developer.webkio.com/docs), with its parameters and responses.

Send the key in the `Authorization` header:

```bash
curl https://api.webkio.com/v1/me \
  -H "Authorization: Bearer wk_your_key_here"
```

Responses are JSON: `{"success": true, "data": ...}` when it worked. Each account can make up to
200 requests a minute.

| Method | Path | What it does |
|---|---|---|
| GET | `/v1/me` | The account the key belongs to, with its timezone. Integrations use it to test the connection. |
| GET | `/v1/projects` | Your sites, with their ids and timezones. |
| GET | `/v1/event-types` | The events a webhook can subscribe to. |
| GET | `/v1/event-types/{type}/samples` | Your newest records for an event type, in exactly the shape a webhook delivers. Optional `project_id`, and `limit` up to 10. |
| GET | `/v1/webhooks` | Your webhooks. |
| POST | `/v1/webhooks` | Subscribe a URL to an event. |
| GET | `/v1/webhooks/{id}` | One webhook. |
| DELETE | `/v1/webhooks/{id}` | Remove a webhook. |
| GET | `/v1/contacts` | Find contacts, on one site or by `email`. |
| POST | `/v1/contacts` | Create a contact, or update the one with the same email. |
| GET | `/v1/contacts/{id}` | One contact. |
| POST | `/v1/subscribers` | Add someone to a site's email subscribers, and to a list. |
| GET | `/v1/email-lists` | Your email lists. |
| POST | `/v1/blog-posts` | Create a blog post draft. |
| GET | `/v1/orders` | Find orders by `number`, `email`, `status` or site. |
| GET | `/v1/orders/{id}` | One order. |
| GET | `/v1/openapi.json` | This API in OpenAPI 3.1, for your own tools. No key needed. |

### Times and time zones

Every timestamp is ISO 8601 in UTC, for example `2026-09-14T09:30:00Z`, so it sorts and compares the
same for everyone. To show a local time, convert it: `/v1/me` gives your account's `timezone`, and
each site (in `/v1/projects` and in every webhook's `project`) gives its own, such as `Europe/London`.
The date and time a customer picked for a booking or a rental are already in the site's local time.

### Pages

Lists come a page at a time. Ask for up to 100 with `limit` (25 if you leave it out). The response's
`meta.has_more` says whether there is more; if there is, pass `meta.next_cursor` as `cursor` to get the
next page.

### Errors

A request that did not work answers with a 4xx or 5xx status and:

```json
{"success": false, "error": {"code": "invalid_target_url", "message": "Webhook URLs must start with https://"}}
```

Use `code` in your code: it never changes. `message` is for people and may be reworded.

| Code | Meaning |
|---|---|
| `missing_api_key`, `invalid_api_key` | No key, or one that is unknown or revoked. |
| `account_unavailable`, `plan_required` | The account is suspended, or its plan does not include integrations (or the CRM, for contacts). |
| `plan_limit_reached` | A limit of your plan is reached: new subscribers this month, or blog posts on a site. |
| `rate_limited` | Over 200 requests a minute. `Retry-After` says when to come back. |
| `not_found`, `resource_not_found` | No such endpoint, or no such record on your account. |
| `unknown_event`, `invalid_target_url`, `invalid_project` | Something in a new webhook is not right; the message says what. |
| `invalid_list` | The `list_id` is not one of that site's email lists. |
| `email_rejected` | Webkio does not send email to that address: it bounced or complained before, or it is a throwaway address. |
| `validation_failed` | A field is missing or not right; the message says which. |
| `webhook_limit_reached` | Your account already has 50 webhooks. |
| `invalid_cursor` | The `cursor` was not one the API gave out. |
| `console_token_expired` | The developer site's temporary token has expired; reload the API reference. |
| `read_only_demo` | The demo account cannot change anything. |

## Adding and finding things

In a request body, a field you leave out or send empty changes nothing. `project_id` is one of the
ids from `/v1/projects`.

### Contacts

`POST /v1/contacts` creates a contact, or updates the one with the same email on that site. Your
site's own orders, bookings and forms add to the same contacts, matched on email.

```bash
curl https://api.webkio.com/v1/contacts \
  -H "Authorization: Bearer wk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"project_id": "2bsxS7dB", "email": "sofia@example.com", "name": "Sofia Almeida", "tags": ["wholesale"]}'
```

It answers `201` with a new contact and `200` with an updated one. `name`, `phone` and `notes`
replace what is stored; `tags` are added to the contact's own and never removed. Find someone with
`GET /v1/contacts?email=sofia@example.com`. Contacts are part of the CRM, so these need a plan that
includes it.

### Email subscribers

`POST /v1/subscribers` with `project_id` and `email`, and optionally `name`, `tags` and a `list_id`
from `GET /v1/email-lists`, adds someone to a site's email subscribers. They are subscribed at once,
as when you add someone in the dashboard, so only send people who agreed to hear from you.

Someone already subscribed is not added twice: they join the list, get the tags and keep their
status, so an address that unsubscribed stays unsubscribed. A new subscriber counts toward your
plan's monthly limit and starts your welcome automation, if you have one.

### Blog post drafts

`POST /v1/blog-posts` with `project_id` and `title`, and optionally `content` (HTML), `excerpt`,
`tags`, `slug`, `meta_title`, `meta_description` and `featured_image` (a URL), creates a **draft**.
Nothing is published through the API: open the post in the dashboard to check it and publish it.

### Orders

`GET /v1/orders` finds orders, newest first, by `number`, customer `email`, `status` or
`project_id`. Each order reads exactly as the `order.created` and `order.paid` webhooks send it.

## Events

| Event | Sent when |
|---|---|
| `order.created` | An order is placed. Card payments may still be pending. |
| `order.paid` | Payment for an order is confirmed. |
| `booking.created` | A customer books an appointment. |
| `rental.reserved` | A customer reserves a rental item. |
| `subscriber.created` | Someone joins your email list. |
| `property_enquiry.created` | A visitor enquires about a property. |
| `review.submitted` | A customer reviews a product. |

## Webhooks

Add one in **Settings > API & webhooks**, or through the API:

```bash
curl https://api.webkio.com/v1/webhooks \
  -H "Authorization: Bearer wk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"event": "order.paid", "target_url": "https://example.com/webkio"}'
```

Add `"project_id"` to hear about one site only; leave it out to hear about every site on your
account. The response includes the webhook's **signing secret**, shown once.

The URL must start with `https://` and point to a public address. Registering the same URL for the
same event again returns the existing webhook rather than a second one.

### What a delivery looks like

Webkio sends a `POST` with a JSON body:

```json
{
  "id": "order.paid_1NAVdi4x",
  "event": "order.paid",
  "project": {"id": "2bsxS7dB", "name": "Your Shop", "timezone": "Europe/London"},
  "data": {
    "id": "1NAVdi4x",
    "number": "ORD-260914-0001",
    "status": "paid",
    "customer": {"name": "Sofia Almeida", "email": "sofia@example.com", "phone": null},
    "currency": "GBP",
    "total": 42.5,
    "items": [{"name": "Linen apron", "sku": null, "quantity": 2, "price": 21.25, "total": 42.5}],
    "created_at": "2026-09-14T09:30:00Z"
  }
}
```

The top-level `id` is the same every time the same record is sent, so you can use it to skip one you
have already handled. A delivery describes the record as it is when it is sent. Call
`GET /v1/event-types/{type}/samples` to see every field of an event with your own data.

Each request carries these headers:

| Header | Contains |
|---|---|
| `X-Webkio-Event` | The event, for example `order.paid`. |
| `X-Webkio-Delivery` | A unique id for this delivery. |
| `X-Webkio-Timestamp` | When it was sent, in Unix seconds. |
| `X-Webkio-Signature` | `v1=` and then the HMAC-SHA256, in hex, of `{timestamp}.{body}` made with your signing secret. |
| `X-Webkio-Test` | `1` on a test sent from the developer console. Absent otherwise. |

### Checking the signature

Work out the signature yourself and compare. Refuse a request whose timestamp is more than five
minutes old, so a captured request cannot be sent again later. Sign the raw body, before you parse
it. Both examples take a list of secrets: normally one, and two while you change it.

```js
// Node.js
const crypto = require('crypto');

function isFromWebkio(rawBody, headers, secrets) {
  const ts = headers['x-webkio-timestamp'];
  const sent = headers['x-webkio-signature'] || '';
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  return fresh && secrets.some((secret) => {
    const expected = 'v1=' + crypto.createHmac('sha256', secret).update(ts + '.' + rawBody).digest('hex');
    return sent.length === expected.length && crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
  });
}
```

```php
// PHP
$valid = false;
foreach ($secrets as $secret) {
    $valid = $valid || hash_equals('v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret), $signature);
}
$valid = $valid && abs(time() - (int) $timestamp) < 300;
```

More languages, ready to paste, are on [developer.webkio.com/webhooks](https://developer.webkio.com/webhooks#verify).

### Changing the signing secret

In the [developer console](https://developer.webkio.com/console), **Rotate signing secret** (under
**More**) creates a new secret and shows it once. Deliveries stay signed with the current secret
until you finish, so none is missed:

1. Set your endpoint to accept both secrets.
2. Send a test from the console. While the secret is being changed, tests are signed with the new one.
3. Choose **Finish**. Every delivery is now signed with the new secret, and you can remove the old one.

**Cancel the new secret** throws it away instead. Webhooks added by Zapier, Make or n8n keep the
secret their integration was given.

### Answering, retries and switching off

- Answer with any `2xx` status within 10 seconds, and do slow work after you have replied.
- Anything else, or no answer, is tried again five more times over about half an hour. A delivery
  you accepted is never sent again.
- Answer `410 Gone` to unsubscribe: the webhook is removed.
- After six failed attempts in a row, the account owner is emailed a warning (at most once a day
  for each webhook). After 30, the webhook is switched off and the owner is emailed again. Once your
  endpoint works, turn it back on in the [developer console](https://developer.webkio.com/console).
- You can also pause a webhook there. A paused or switched-off webhook misses what happens
  meanwhile: nothing is sent later.

Deliveries usually go out within a minute of the event. The console's delivery log shows every
attempt and what your endpoint answered, and can send a delivery again.
