BillKit/DocsConsole
Webhook endpoints

Create a webhook endpoint

Registers a URL to deliver events to and returns the signing secret you verify deliveries against.

POST/v1/webhook_endpoints
Operation create_webhook_endpoint·Scope webhook_endpoints:write·OpenAPI v1.1.0

Parameters

ParameterDescription
urlstring (uri)requiredThe URL BillKit sends events to: http or https, with a public host. Private and loopback addresses are refused. Must be an absolute URL. At most 2083 characters.
descriptionstring | nulloptionalAn optional note about the endpoint. At most 255 characters.
enabled_eventsarray of stringoptionalThe event types to send to this endpoint, as listed by GET /v1/webhook_endpoints/event_types, or * for every event, including types added later. Defaults to ["*"].

Returns

The endpoint, with secret populated. This is the only response that carries this secret: every later read returns secret_fingerprint instead. Store the secret when you get it. If you lose it, rotate: the rotate response carries the new one, once.

Leave enabled_events off and the endpoint receives everything. An unknown event type is a 400; GET /v1/webhook_endpoints/event_types lists the valid ones. A mode holds at most 16 endpoints, disabled ones included, and creating one more is a 409 with code webhook_endpoint_limit_reached. Rotating the secret signs every delivery with both the old and the new secret for 24 hours, then with the new one only, so read Rotating a secret before you do it on a live endpoint.

Verify every payload. Check the BillKit-Signature header against this endpoint’s secret before trusting the body. See Verify the signature.

Test it

Point the endpoint at a tunnel, or skip registration entirely and forward deliveries straight to localhost with billkit listen.

Errors

StatusCause
400The request was refused (type: invalid_request_error): a required parameter is missing (parameter_missing), one failed validation (parameter_invalid) or is not one the operation takes (parameter_unknown), and param names it.
401No credential, or one that does not authenticate.
402The account is suspended (tenant_suspended), or the payment provider refused a charge or refund the call made (type: payment_error, code: payment_refused).
403Authenticated, but not allowed: missing scope (insufficient_scope), a key of the wrong mode (mode_mismatch), or a refused origin. Needs the webhook_endpoints:write scope.
404No such object in this account and mode.
409Conflicts with the object's current state (state_conflict, or a more specific code), or an operation still in flight (operation_pending).
413The request body is larger than the API accepts.
429Rate limited. Retry-After says when to try again.
500Something failed on BillKit's side (type: api_error). Retry with the same Idempotency-Key.
502The payment provider failed or did not answer.
503Temporarily unable to serve. reason says whether the request was attempted.
Receive a delivery · Node.js
import {
  isKnownEventType,
  verifyWebhookSignature,
  WebhookVerificationError,
  type UnknownWebhookEvent,
  type WebhookEvent,
} from "@billkit-eu/sdk";

const secret = process.env.BILLKIT_WEBHOOK_SECRET;
if (!secret) throw new Error("BILLKIT_WEBHOOK_SECRET is not set");

// Express here; any framework works as long as it hands you the raw body.
app.post("/webhooks/billkit", express.raw({ type: "application/json" }), async (req, res) => {
  const header = req.headers["billkit-signature"];
  let event: WebhookEvent | UnknownWebhookEvent;
  try {
    event = await verifyWebhookSignature({
      payload: req.body,
      signatureHeader: typeof header === "string" ? header : undefined,
      secret,
    });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      res.status(400).send("invalid signature");
      return;
    }
    throw err;
  }

  // A type this SDK version does not know yet: acknowledge it and move on.
  if (isKnownEventType(event) && event.type === "invoice.payment_failed") {
    await dunning.start(event.data.customer_id);
  }
  res.json({ received: true });
});
Receive a delivery · Python
import os

from billkit import WebhookSignature, WebhookVerificationError

@app.post("/webhooks/billkit")
def billkit_webhook():
    try:
        event = WebhookSignature.verify(
            payload=request.data,
            signature_header=request.headers.get("BillKit-Signature"),
            secret=os.environ["BILLKIT_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return "invalid signature", 400

    if event["type"] == "invoice.payment_failed":
        dunning.start(event["data"]["customer_id"])

    return {"received": True}
Receive a delivery · PHP
<?php

use BillKit\Webhooks;
use BillKit\Exception\WebhookVerificationException;

$secret = getenv('BILLKIT_WEBHOOK_SECRET');
if ($secret === false || $secret === '') {
    throw new \RuntimeException('BILLKIT_WEBHOOK_SECRET is not set');
}

try {
    $event = Webhooks::verifySignature(
        payload: file_get_contents('php://input'),
        signatureHeader: $_SERVER['HTTP_BILLKIT_SIGNATURE'] ?? null,
        secret: $secret,
    );
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit('invalid signature');
}

if ($event['type'] === 'invoice.payment_failed') {
    Dunning::start($event['data']['customer_id']);
}
Receive a delivery · Laravel
<?php

namespace App\Listeners;

use BillKit\Laravel\Events\WebhookReceived;

// The package registers POST /billkit/webhook and verifies the
// signature before dispatching this event.
class HandleBillKitWebhook
{
    public function handle(WebhookReceived $event): void
    {
        if ($event->payload['type'] === 'invoice.payment_failed') {
            Dunning::start($event->payload['data']['customer_id']);
        }
    }
}
Receive a delivery · React
// Webhooks are server to server, and the signing secret must never
// reach a browser. Handle the event on your backend and let the page
// read the state your own API exposes afterwards.
Request · curl
curl -X POST https://api.billkit.eu/v1/webhook_endpoints \
  -H "Authorization: Bearer $BILLKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://acme.example/welcome"}'
Request body · every field
{
  "description": "string",
  "enabled_events": [
    "string"
  ],
  "url": "https://acme.example/welcome"
}
Response · 200 OK
{
  "created": 1789392000,
  "description": "string",
  "enabled_events": [
    "string"
  ],
  "id": "we_9XKp2vQ1",
  "livemode": false,
  "object": "webhook_endpoint",
  "previous_secret_fingerprint": "string",
  "previous_secret_retired_at": 1789392000,
  "secret": "string",
  "secret_fingerprint": "string",
  "status": "enabled",
  "url": "https://acme.example/welcome"
}