Create an endpoint
The endpoint is the address in your system that receives the events. Registering it is a single step, in the dashboard or through the API. This guide covers both paths and ends by checking that deliveries are arriving.
Before registering
Section titled “Before registering”- Who registers. Webhooks are managed by the owner and admins, in the dashboard or with an API key of a member with that role. Other roles get 403 on every webhook route, including the read ones.
- A public
https://address. Internal addresses,localhostandhttp://are refused, and the check runs again on every delivery. The full rules are in Where a delivery can go. To test from your own machine, you need to expose your local server at a publichttps://address, for example with a tunneling service. - A server that checks the signature. Have the receiver ready first, with the code from Verify the signature. It answers 200 to a signed delivery and 400 to anything else.
- The events you use. Pick from the Event catalog only what your system will handle. Receiving an event means receiving its data.
How many endpoints the company can have depends on the plan: see Limits.
In the dashboard
Section titled “In the dashboard”- Open the Settings page: click your name at the top of the screen, then Settings. From the side menu, the path is Account → Settings.
- Go to the API tab. The Webhooks card sits below the keys.
- Click New endpoint.
- Fill in the URL, a Description to recognize later which system receives the events, and check the Events. All events also includes the ones added to the catalog later.
- Confirm with your password and click Create endpoint.
The next screen shows the secret, which starts with whsec_. Copy it to the server that receives the events. In the dashboard, the owner and admins can reveal the secret again later, from the endpoint menu (Reveal secret), confirming the password.
The dashboard asks for the password when creating and whenever the URL or the events change, because those two fields decide where the company’s data goes.
Through the API
Section titled “Through the API”The key needs:
| Permission | What for |
|---|---|
webhooks:create | Register the endpoint |
<module>:read for each event | Subscribe: sales:read for sale.*, clients:read for client.*, and so on. With ["*"], for every module |
webhooks:update | Send the ping, rotate the secret, turn it on and off. Changing the URL or the events, turning it back on, sending the ping and rotating the secret also need <module>:read for every event the endpoint subscribes to, even if it was created in the dashboard |
webhooks:read | Read the endpoint and the delivery log |
# 1. Register# Generate the key once and keep it: on a retry, repeat with the same value.CREATE_KEY=$(uuidgen)curl -X POST "https://api.fatureihoje.com/public/v1/webhook_endpoints" \ -H "Authorization: Bearer $FH_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $CREATE_KEY" \ -d '{ "url": "https://erp.yourcompany.com/webhooks/faturei-hoje", "description": "Store ERP", "events": ["sale.created", "sale.paid", "client.created"] }'
# 2. Check the connection, with the id that came back aboveENDPOINT_ID="7f3a1c5e-9b2d-4f6a-8c0e-2b4d6f8a0c1e"# Generate the key once and keep it: on a retry, repeat with the same value.PING_KEY=$(uuidgen)curl -X POST "https://api.fatureihoje.com/public/v1/webhook_endpoints/$ENDPOINT_ID/ping" \ -H "Authorization: Bearer $FH_API_KEY" \ -H "Idempotency-Key: $PING_KEY"
# 3. A few seconds later, see how the delivery wentcurl "https://api.fatureihoje.com/public/v1/webhook_endpoints/$ENDPOINT_ID/deliveries?limit=5" \ -H "Authorization: Bearer $FH_API_KEY"import { randomUUID } from 'node:crypto';
const API = 'https://api.fatureihoje.com/public/v1';
async function call(method, path, body, idempotencyKey) { const headers = { Authorization: `Bearer ${process.env.FH_API_KEY}` }; if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; if (body !== undefined) headers['Content-Type'] = 'application/json'; const res = await fetch(`${API}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), }); const data = res.status === 202 ? null : await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`); return data;}
// One key per operation, generated before the first attempt. On a retry, reuse// it: that is what makes the API return the stored response instead of writing// again.const createKey = randomUUID();const pingKey = randomUUID();
// 1. Register.const endpoint = await call('POST', '/webhook_endpoints', { url: 'https://erp.yourcompany.com/webhooks/faturei-hoje', description: 'Store ERP', events: ['sale.created', 'sale.paid', 'client.created'],}, createKey);// Store endpoint.secret on the server that receives the events. Through the// API, it does not show up again. Do not write the secret to logs.console.log('endpoint', endpoint.id, endpoint.status);
// 2. Check the connection.await call('POST', `/webhook_endpoints/${endpoint.id}/ping`, undefined, pingKey);
// 3. A few seconds later, see how the delivery went.await new Promise((resolve) => setTimeout(resolve, 5000));const deliveries = await call('GET', `/webhook_endpoints/${endpoint.id}/deliveries?limit=5`);for (const delivery of deliveries.data) { console.log(delivery.event_type, delivery.status, delivery.response_status_code, delivery.error);}<?php
const API = 'https://api.fatureihoje.com/public/v1';
function call(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): ?array{ $headers = ['Authorization: Bearer ' . getenv('FH_API_KEY')]; if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } if ($body !== null) { $headers[] = 'Content-Type: application/json'; } $ch = curl_init(API . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $data = $status === 202 ? null : json_decode($raw, true); if ($status >= 400) { throw new RuntimeException($status . ' ' . $data['error']['code'] . ': ' . $data['error']['message']); } return $data;}
// One key per operation, generated before the first attempt. On a retry, reuse// it: that is what makes the API return the stored response instead of writing// again.$createKey = bin2hex(random_bytes(16));$pingKey = bin2hex(random_bytes(16));
// 1. Register.$endpoint = call('POST', '/webhook_endpoints', [ 'url' => 'https://erp.yourcompany.com/webhooks/faturei-hoje', 'description' => 'Store ERP', 'events' => ['sale.created', 'sale.paid', 'client.created'],], $createKey);// Store $endpoint['secret'] on the server that receives the events. Through the// API, it does not show up again. Do not write the secret to logs.echo 'endpoint ', $endpoint['id'], ' ', $endpoint['status'], PHP_EOL;
// 2. Check the connection.call('POST', '/webhook_endpoints/' . $endpoint['id'] . '/ping', null, $pingKey);
// 3. A few seconds later, see how the delivery went.sleep(5);$deliveries = call('GET', '/webhook_endpoints/' . $endpoint['id'] . '/deliveries?limit=5');foreach ($deliveries['data'] as $delivery) { echo $delivery['event_type'], ' ', $delivery['status'], PHP_EOL;}import jsonimport osimport timeimport urllib.errorimport urllib.requestimport uuid
API = "https://api.fatureihoje.com/public/v1"
def call(method, path, body=None, idempotency_key=None): headers = {"Authorization": f"Bearer {os.environ['FH_API_KEY']}"} if idempotency_key: headers["Idempotency-Key"] = idempotency_key data = None if body is not None: headers["Content-Type"] = "application/json" data = json.dumps(body).encode() request = urllib.request.Request(API + path, data=data, method=method, headers=headers) try: with urllib.request.urlopen(request) as response: return None if response.status == 202 else json.load(response) except urllib.error.HTTPError as failure: error = json.load(failure)["error"] raise SystemExit(f"{failure.code} {error['code']}: {error['message']}")
# One key per operation, generated before the first attempt. On a retry, reuse# it: that is what makes the API return the stored response instead of writing# again.create_key = str(uuid.uuid4())ping_key = str(uuid.uuid4())
# 1. Register.endpoint = call( "POST", "/webhook_endpoints", { "url": "https://erp.yourcompany.com/webhooks/faturei-hoje", "description": "Store ERP", "events": ["sale.created", "sale.paid", "client.created"], }, create_key,)# Store endpoint["secret"] on the server that receives the events. Through the# API, it does not show up again. Do not write the secret to logs.print("endpoint", endpoint["id"], endpoint["status"])
# 2. Check the connection.call("POST", f"/webhook_endpoints/{endpoint['id']}/ping", None, ping_key)
# 3. A few seconds later, see how the delivery went.time.sleep(5)deliveries = call("GET", f"/webhook_endpoints/{endpoint['id']}/deliveries?limit=5")for delivery in deliveries["data"]: print(delivery["event_type"], delivery["status"])The registration response is 201 and carries the endpoint with the secret field. Through the API, the secret only shows up in this response and on rotation. Repeating the registration with the same Idempotency-Key returns the endpoint without the secret, because it is not stored in the clear, not even for the replay. If you lost the response, rotate the secret with POST /public/v1/webhook_endpoints/{id}/rotate_secret, or reveal it in the dashboard.
The owner and admins get an email for every endpoint created.
Check the connection
Section titled “Check the connection”The ping (POST /public/v1/webhook_endpoints/{id}/ping, or Send ping in the endpoint menu in the dashboard) sends a real ping event to that endpoint only, signed and down the same path as any event. It carries no company data. It is not a test environment: it only proves the address receives and checks the signature.
Then open the delivery log (GET /public/v1/webhook_endpoints/{id}/deliveries, or View deliveries in the dashboard):
| What you see | What it means |
|---|---|
succeeded | Your server answered 2xx. Done |
pending, with response_status_code | The server answered, but not 2xx. Another attempt comes later |
pending, with error | There was no answer (timeout, DNS, TLS, connection). The reason is in the field |
failed | The attempts ran out, or the delivery was closed without being sent. See Deliveries and retries |
A 400 on the ping is almost always a signature that did not match on your side: the wrong secret, or the body parsed before the check. See Verify the signature.
When registration is refused
Section titled “When registration is refused”| Response | Why |
|---|---|
422 with param url | The URL is not https://, is not public, has a username and password, or does not resolve |
422 with param events | An event that is not in the catalog, or * together with another name |
403 permission_missing | Missing webhooks:create, or the <module>:read for one of the chosen events |
403 plan_limit_reached | The company reached the plan’s endpoint limit |
403 member_permission_denied | The member behind the key is neither the owner nor an admin |
Next step
Section titled “Next step”- Verify the signature: the receiver code.
- Deliveries and retries: what happens when your server fails.
- Best practices: answer fast, ignore duplicates and order events.