Receive and verify webhooks

Get a signed HTTPS request the moment something happens on your account: a payment, an invoice, a number change or a port order update. This guide shows you how to check that each request came from cloudnumbering, handle retries safely and read every event.

Our webhooks follow the Standard Webhooks specification, so any Standard Webhooks library can verify them. You don't need a cloudnumbering SDK.

Add a webhook

  1. In the portal settings, open Webhooks and select Add webhook.
  2. Enter your endpoint's URL and choose the events to send. The URL must use https on port 443 and must not contain a username or password.
  3. Copy the signing secret. It starts with whsec_ and is shown only once. Store it like a password, for example in your secret manager.
  4. Select Send test event, then Turn on webhook.

If you lose the secret, rotate it. See Rotate the signing secret.

What we send

Each delivery is a POST with these headers:

HeaderValue
webhook-idThe delivery's ID, such as DL00000000000000000000000000000001. It stays the same on every retry and resend.
webhook-timestampUnix time in seconds when we signed this attempt. It's new on each attempt.
webhook-signatureOne or more space-separated v1,<base64 signature> values.
content-typeapplication/cloudevents+json; charset=utf-8
user-agentStarts with Cloudnumbering-Webhooks/1.0.

The body is a CloudEvents 1.0 JSON object, at most 64 KiB:

{
  "specversion": "1.0",
  "id": "0198a5d0-0001-7000-8000-000000000001",
  "source": "https://api.cloudnumbering.com",
  "type": "payment.succeeded.v1",
  "subject": "PY00000000000000000000000000000001",
  "time": "2026-09-29T10:15:00.000Z",
  "datacontenttype": "application/json",
  "organisation": "OR00000000000000000000000000000001",
  "data": {
    "object": {
      "sid": "PY00000000000000000000000000000001",
      "status": "succeeded",
      "amount": "25.00",
      "currency": "GBP",
      "completedAt": "2026-09-29T10:15:00.000Z"
    }
  }
}
FieldMeaning
idThe event's ID: an opaque UUID, unique per event. Don't rely on its UUID version. The same event sent to two of your webhooks has one id but two webhook-ids.
typeThe event type, ending in its version, such as .v1. See the event catalogue.
subjectThe SID of the resource the event is about. Always equal to data.object.sid.
timeWhen the change happened, in UTC. Not when we sent it.
organisationYour organisation SID.
testtrue on test events. Absent on real ones.
dataThe event's payload. data.object is a small snapshot of the resource.

Verify each request

Before you trust a request, check its signature against the raw request body:

  • Read the body as bytes before any JSON parsing. A parsed and re-serialised body won't match the signature.
  • Reject a webhook-timestamp more than 5 minutes from your clock. This stops someone replaying an old request.
  • Accept the request if any signature in webhook-signature matches. During a secret rotation we send two.
  • Compare signatures in constant time.

The Standard Webhooks libraries do all four. Pass them your whsec_ secret as it is.

Go

This example uses standard-webhooks/libraries/go:

go get github.com/standard-webhooks/standard-webhooks/[email protected]
package webhooks

import (
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"sync"

	standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)

// Event is the CloudEvents envelope of every Cloudnumbering webhook.
type Event struct {
	ID      string          `json:"id"`
	Type    string          `json:"type"`
	Subject string          `json:"subject"`
	Time    string          `json:"time"`
	Test    bool            `json:"test"`
	Data    json.RawMessage `json:"data"`
}

// Receiver verifies each request, then hands every delivery to Handle once.
type Receiver struct {
	Webhook *standardwebhooks.Webhook
	Handle  func(Event) error

	// state maps each webhook-id to processing or done. In production, store
	// handled ids in your database, under a unique key, in the same
	// transaction as your changes.
	state sync.Map
}

const (
	processing = iota
	done
)

// NewReceiver takes the whsec_ signing secret shown when you created the webhook.
func NewReceiver(secret string, handle func(Event) error) (*Receiver, error) {
	if secret == "" {
		return nil, errors.New("missing webhook signing secret")
	}
	wh, err := standardwebhooks.NewWebhook(secret)
	if err != nil {
		return nil, err
	}
	return &Receiver{Webhook: wh, Handle: handle}, nil
}

func (rc *Receiver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Verify the raw bytes. Parse JSON only after the signature checks out.
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	if err := rc.Webhook.Verify(body, r.Header); err != nil {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	// Retries and replays reuse the webhook-id. Acknowledge one you've handled.
	id := r.Header.Get("webhook-id")
	if state, seen := rc.state.LoadOrStore(id, processing); seen {
		if state == done {
			w.WriteHeader(http.StatusNoContent)
		} else {
			http.Error(w, "still processing", http.StatusConflict) // We'll retry later.
		}
		return
	}

	var event Event
	if err := json.Unmarshal(body, &event); err != nil {
		rc.state.Delete(id)
		http.Error(w, "invalid event", http.StatusBadRequest)
		return
	}
	if event.Test {
		log.Printf("test event %s received", event.Type)
		rc.state.Store(id, done)
		w.WriteHeader(http.StatusNoContent)
		return
	}
	if err := rc.Handle(event); err != nil {
		rc.state.Delete(id) // Let our retry try again.
		http.Error(w, "try again later", http.StatusServiceUnavailable)
		return
	}
	rc.state.Store(id, done)
	w.WriteHeader(http.StatusNoContent)
}

Mount it like any http.Handler:

rc, err := webhooks.NewReceiver(os.Getenv("CLOUDNUMBERING_WEBHOOK_SECRET"), handlePayment)
if err != nil {
	log.Fatal(err)
}
http.Handle("POST /cloudnumbering/webhooks", rc)

TypeScript

This example uses the standardwebhooks package:

pnpm add standardwebhooks
import { Webhook, WebhookVerificationError } from "standardwebhooks";

/** The CloudEvents envelope of every Cloudnumbering webhook. */
export interface WebhookEvent {
  id: string;
  type: string;
  subject: string;
  time: string;
  test?: true;
  data: Record<string, unknown>;
}

/**
 * Returns a fetch-style handler (Request in, Response out) that verifies each
 * request, then hands every delivery to `handle` once.
 * `secret` is the whsec_ signing secret shown when you created the webhook.
 */
export function createReceiver(
  secret: string,
  handle: (event: WebhookEvent) => Promise<void>,
) {
  const webhook = new Webhook(secret);
  // Each webhook-id's state. In production, store handled ids in your database,
  // under a unique key, in the same transaction as your changes.
  const state = new Map<string, "processing" | "done">();

  return async (request: Request): Promise<Response> => {
    // Verify the raw body. Never re-serialise parsed JSON before verifying.
    const body = await request.text();
    let event: WebhookEvent;
    try {
      // verify() checks the timestamp, accepts any signature in the list and
      // compares in constant time. It returns the parsed body.
      event = webhook.verify(body, Object.fromEntries(request.headers)) as WebhookEvent;
    } catch (error) {
      if (error instanceof WebhookVerificationError) {
        return new Response("invalid signature", { status: 401 });
      }
      throw error;
    }

    // Retries and replays reuse the webhook-id. Acknowledge one you've handled.
    const id = request.headers.get("webhook-id")!;
    const seen = state.get(id);
    if (seen === "done") return new Response(null, { status: 204 });
    if (seen === "processing") return new Response("still processing", { status: 409 }); // We'll retry later.
    state.set(id, "processing");

    if (event.test) {
      console.log(`test event ${event.type} received`);
    } else {
      try {
        await handle(event);
      } catch {
        state.delete(id); // Let our retry try again.
        return new Response("try again later", { status: 503 });
      }
    }
    state.set(id, "done");
    return new Response(null, { status: 204 });
  };
}

The handler takes a standard Request and returns a Response, so it works as is in Bun, Deno, Cloudflare Workers, Hono and Next.js route handlers. In Express, mount the route with express.raw({ type: "*/*" }) so the body reaches you unparsed.

Other languages

Standard Webhooks also has libraries for Python, Ruby, PHP, Java, C#, Rust and Elixir. To verify by hand:

  1. Remove the whsec_ prefix from the secret and base64-decode the rest. That gives 32 bytes.
  2. Compute HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body> with those bytes, then base64-encode it.
  3. Compare the result in constant time with the value after v1, in each webhook-signature entry.

Respond quickly

Return any 2xx status within 15 seconds to tell us the delivery worked.

Anything else counts as a failure and we retry it:

  • a 3xx, 4xx or 5xx status. We don't follow redirects.
  • no response within 15 seconds
  • a DNS, connection or TLS error

If your work takes more than a few seconds, store the event, return 2xx and process it in the background.

A 410 Gone response turns the webhook off. Send it only when you want deliveries to stop. Turn the webhook back on in the portal.

Retries

We retry failed deliveries for up to 72 hours. Each attempt's target time is counted from when we created the delivery:

Attempt12345678
Sent afterAt once1 min5 min30 min2 h8 h24 h72 h
  • Times vary by up to 10% so retries don't all arrive at once. Attempts are always at least 30 seconds apart.
  • On a 429 or 503, we wait at least as long as your Retry-After header says, up to 24 hours, but never past the 72 hours.
  • If the eighth attempt fails, the delivery fails. For 30 days, you can select it in the Delivery log in the portal and choose Resend.
  • Test events and resends get one attempt each, with no retries.

Handle duplicates and order

We deliver each event at least once. You may get the same delivery more than once, for example after a timeout or a resend.

  • Deduplicate on webhook-id. Record each one you've processed, in the same database transaction as the changes it causes, with a unique key. If you see it again, return 2xx and do nothing. If it arrives while you're still processing it, return a non-2xx status such as 409 so we retry later, as the examples do.
  • Across webhooks, use the body's id. If two of your webhooks receive the same event, they get different webhook-ids but the same id.
  • Or re-read current state on every event. If your handler only fetches the resource from the API and stores what it finds, a duplicate does no harm, so you don't need to track webhook-id.
  • Don't rely on order. Events can arrive in any order, especially after retries. Use time to see when each change happened. When you need a resource's current state, fetch it from the API.

Rotate the signing secret

Rotate the secret from the webhook's page in the portal with Rotate secret. We show the new secret once.

For 24 hours after a rotation, we sign every request with both the old and new secrets, so webhook-signature carries two values. Update your receiver with the new secret during that window. Both the old and new secrets verify, because the libraries accept any matching signature.

If the old secret leaked, choose Stop the old secret now instead. We stop signing with it straight away. A request signed just before may still arrive within 15 seconds.

Test events

Send test event delivers a webhook.test.v1 event, even while the webhook is off:

{
  "type": "webhook.test.v1",
  "subject": "WD00000000000000000000000000000001",
  "test": true,
  "data": {
    "object": { "sid": "WD00000000000000000000000000000001", "name": "Production receiver" },
    "message": "This is a test event from Cloudnumbering."
  }
}

Test events can carry example payloads with fake SIDs, so check test before you act on any event. Both examples above do.

Which events you receive

A webhook receives an event if it was on and subscribed to that type when the change happened, going by the event's time. Turning a webhook on, or adding an event type, never sends older events.

We never send events that happened while a webhook was off. Resend in the Delivery log works only for deliveries already there, such as failed ones or ones we cancelled when the webhook was turned off.

Allow our addresses

If your firewall filters by source address, allow HTTPS from all three:

  • 13.41.154.11
  • 18.135.51.94
  • 18.171.64.227

An allowlist doesn't prove a request came from us. Always verify the signature too.

Event catalogue

We may add fields and codes to any type below without changing its version. Ignore fields you don't recognise and handle unknown codes. We leave out optional fields rather than sending null. A breaking change gets a new type, such as .v2.

Amounts are decimal strings in major units with a currency. Parse them as decimals, never as floating-point numbers.

The examples show data only. Every event has the envelope shown in What we send.

Billing

payment.succeeded.v1: a payment succeeded. The subject is the payment's PY SID.

{ "object": { "sid": "PY00000000000000000000000000000001", "status": "succeeded", "amount": "25.00", "currency": "GBP", "completedAt": "2026-09-29T10:15:00.000Z" } }

payment.failed.v1: a payment failed. type is the payment kind, such as top_up. origin is automatic or customer_initiated. reason.code is a failure code, such as payment_method_declined.

{ "object": { "sid": "PY00000000000000000000000000000001", "status": "failed", "type": "top_up", "origin": "automatic", "amount": "25.00", "currency": "GBP" }, "reason": { "code": "payment_method_declined" } }

invoice.available.v1: an invoice is ready. The subject is the invoice's IN SID. related.paymentSid is present only when the invoice has a related payment. The event doesn't include a download link.

{ "object": { "sid": "IN00000000000000000000000000000001", "status": "available", "amount": "25.00", "currency": "GBP", "availableAt": "2026-08-11T14:23:42.123Z" }, "related": { "paymentSid": "PY00000000000000000000000000000001" } }

balance.credited.v1: your balance went up. The subject is the credit's TR transaction SID. origin is customer_initiated, automatic, adjustment or introductory. Amounts have four decimal places. availableBalance is the balance at balanceObservedAt, not a live balance.

{ "object": { "sid": "TR00000000000000000000000000000001", "origin": "automatic", "amount": "25.0000", "currency": "GBP", "availableBalance": "125.4837", "balanceObservedAt": "2026-08-11T14:23:42.123Z" }, "related": { "paymentSid": "PY00000000000000000000000000000001" } }

balance.low.v1: your balance is below your low-balance alert threshold. The subject is your organisation's OR SID. Set the threshold under Low-balance alerts in Notification settings. We check every hour and, while your balance stays below the threshold, send at most one event every 20 hours. We send it even if the low-balance email has no recipients. balance is your balance when we checked, not a live balance. It's negative when you're overdrawn. Amounts have four decimal places.

{ "object": { "sid": "OR00000000000000000000000000000001", "balance": "4.2500", "threshold": "10.0000", "currency": "GBP" } }

auto_topup.disabled.v1: we turned off your automatic card top-ups because top-up payments kept failing. The subject is your organisation's OR SID. failureCount is how many top-up payments failed in a row. To turn them back on, see Setting up automatic card top-ups.

{ "object": { "sid": "OR00000000000000000000000000000001", "failureCount": 3 } }

Numbers

Number events use the assigned number's AN SID as the subject and include phoneNumber in E.164 format. Disconnected, reconnected and released events also include reference, your own reference for the number. We leave it out when the number has none. Treat an unknown reason.code as other.

number.assigned.v1: a number is active on your account. reason.code is purchased, ported_in or other.

{ "object": { "sid": "AN00000000000000000000000000000001", "status": "active", "phoneNumber": "+442079460123", "assignedAt": "2026-08-11T14:23:42.123Z" }, "reason": { "code": "purchased" } }

number.disconnected.v1: a number was disconnected. reason.code is customer_requested, overdue_billing, port_out, admin_support, internal_maintenance or other.

{ "object": { "sid": "AN00000000000000000000000000000001", "status": "disconnected", "phoneNumber": "+442079460123", "reference": "main line" }, "previousStatus": "active", "reason": { "code": "customer_requested" } }

number.reconnected.v1: a disconnected number is active again. reason.code is customer_requested, admin_support, system or other.

{ "object": { "sid": "AN00000000000000000000000000000001", "status": "active", "phoneNumber": "+442079460123", "reference": "main line" }, "previousStatus": "disconnected", "reason": { "code": "customer_requested" } }

number.released.v1: a number left your account. previousStatus is active or disconnected. reason.code is customer_requested, disconnected_retention_expired, port_out, used_as_port_in_donor or other.

{ "object": { "sid": "AN00000000000000000000000000000001", "status": "released", "phoneNumber": "+442079460123", "reference": "main line" }, "previousStatus": "active", "reason": { "code": "customer_requested" } }

Porting

Port order events use the port order's PO SID as the subject. direction is inbound or outbound. assignedNumberSid is the ported number's AN SID. It's always present on outbound and completed orders, and on inbound orders once we've created the number. phoneNumber is the order's phone number in E.164 format. Events from before we added it don't have it. Events never include PAC or STAC codes.

The API reports port orders with different values. A webhook's status is from when the event happened. The API's status is the order's state now:

Webhook statusAPI status
createdIN_PROGRESS, or CODE_READY for a port-out whose code is ready
submittedIN_PROGRESS
lockedIN_PROGRESS
code_readyCODE_READY, or CODE_EXPIRED once the code expires
completedCOMPLETED
failedFAILED
cancelledCANCELLED
expiredCODE_EXPIRED

Webhook direction is inbound or outbound. The API uses IN and OUT.

port_order.created.v1: a port order was created.

{ "object": { "sid": "PO00000000000000000000000000000001", "status": "created", "direction": "inbound", "numberCount": 1, "phoneNumber": "+442079460123" } }

port_order.updated.v1: a port order moved to submitted or locked, or its confirmed port date changed. status is the order's status at the time, so a date change can repeat a status you've already had. confirmedPortDate is the port date the carrier confirmed, as a UK calendar date (YYYY-MM-DD, Europe/London). It's left out until a date is confirmed.

{ "object": { "sid": "PO00000000000000000000000000000001", "status": "locked", "direction": "inbound", "numberCount": 1, "phoneNumber": "+442079460123", "confirmedPortDate": "2026-08-14" } }

port_order.code_ready.v1: a port-out's authorisation code (PAC or STAC) is ready. direction is always outbound. codeExpiresAt, when present, is when the code expires. The event doesn't include the code. Get it from the code field of Get a port order (GET /v1.1/porting/orders/{sid}), which returns it while the order's status is CODE_READY.

{ "object": { "sid": "PO00000000000000000000000000000001", "status": "code_ready", "direction": "outbound", "numberCount": 1, "assignedNumberSid": "AN00000000000000000000000000000001", "phoneNumber": "+442079460123", "codeExpiresAt": "2026-09-10T14:23:42.000Z" } }

port_order.completed.v1: the port finished.

{ "object": { "sid": "PO00000000000000000000000000000001", "status": "completed", "direction": "inbound", "numberCount": 1, "completedAt": "2026-08-11T14:23:42.123Z", "assignedNumberSid": "AN00000000000000000000000000000001", "phoneNumber": "+442079460123" } }

port_order.failed.v1: the port failed. reason.code is expired_pac, pac_msisdn_mismatch, port_date_rejected or other.

{ "object": { "sid": "PO00000000000000000000000000000001", "status": "failed", "direction": "inbound", "numberCount": 1, "phoneNumber": "+442079460123" }, "reason": { "code": "expired_pac" } }

port_order.cancelled.v1: the port was cancelled. reason.code is customer_requested, provider_cancelled or other.

{ "object": { "sid": "PO00000000000000000000000000000001", "status": "cancelled", "direction": "inbound", "numberCount": 1, "phoneNumber": "+442079460123" }, "reason": { "code": "customer_requested" } }

port_order.expired.v1: the port's authorisation code expired.

{ "object": { "sid": "PO00000000000000000000000000000001", "status": "expired", "direction": "inbound", "numberCount": 1, "phoneNumber": "+442079460123" } }

Renewals

renewal.failed.v1: some of your numbers failed to renew. The subject is your organisation's OR SID. We send at most one a day (UTC). failedCount is how many numbers failed. The event doesn't list them. reason.code is insufficient_funds when every failure was a low balance, so a top-up fixes it. It's other when none was, so contact support, and mixed when some were. Treat an unknown code as other. disconnectAfter is the UTC date from which numbers that are still unrenewed may be disconnected for non-payment. It's left out when they won't be disconnected.

{ "object": { "sid": "OR00000000000000000000000000000001", "failedCount": 3, "disconnectAfter": "2026-08-18" }, "reason": { "code": "insufficient_funds" } }

To find the numbers and fix them, see Fixing failed number renewals.

Troubleshooting

Every request fails verification

  • Check you're using this webhook's current secret, including the whsec_ prefix.
  • Check nothing changes the body before you verify it. Body-parsing middleware is the usual cause.
  • Check your server's clock. A clock more than 5 minutes out rejects every request.

A delivery shows as failed but your server handled it

Your server probably took longer than 15 seconds or returned a non-2xx status. Open the delivery in the Delivery log to see the status we received. Because you deduplicate on webhook-id, our retry is safe.

The webhook turned itself off

Your endpoint returned 410 Gone, or its hostname resolved to a private or reserved address. Fix the endpoint, select Send test event, then turn the webhook on again.

Next steps


Did this page help you?