_Docs/
Get StartedModulesPlatformDeployCookbookChangelogReference
_Stack
_Modules
  • Ledger
  • Numscript
  • Payments
  • WalletsEE
  • FlowsEE
  • ReconciliationEE
  • WebhooksEE
    • Receiving Webhooks
    • Delivery Lifecycle and Guarantees
    • Event Reference
  1. Modules
  2. Webhooks
  3. Receiving Webhooks
Webhooks

Receiving Webhooks

A production webhook endpoint must authenticate the sender, preserve each delivery until it can be processed, and remain safe when the same delivery arrives more than once.

Request contract#

Webhooks sends an HTTP POST with a JSON body and the following headers:

HeaderDescription
formance-webhook-idIdentifier for one delivery chain. It remains stable across automatic retries and, where replay is available, manual replay.
formance-webhook-timestampUnix timestamp generated for this HTTP attempt. Each retry receives a new timestamp.
formance-webhook-signatureOne or more versioned signatures. The current format is v1,<base64-HMAC>.
formance-webhook-idempotency-keySource idempotency key, when the module that produced the event supplied one. Do not assume it is always present.
formance-webhook-testtrue for a request sent by the configuration test endpoint, otherwise false.
Content-Typeapplication/json.

Webhooks signs the raw request body, including whitespace and field ordering.

See the event reference for the Stack v3.2 envelope and payload catalog.

Verify the signature#

For signature version v1, Webhooks computes HMAC-SHA256 over:

{formance-webhook-id}.{formance-webhook-timestamp}.{raw-request-body}

The result is base64-encoded and sent as v1,<signature>. The verification helper compares signatures in constant time, but it does not enforce timestamp freshness. Apply your own tolerance after parsing the timestamp.

go
package example

import (
	"fmt"
	"io"
	"net/http"
	"strconv"
	"time"

	"github.com/formancehq/webhooks/pkg/security"
)

const signatureTolerance = 5 * time.Minute

func VerifyWebhook(r *http.Request, secret string) ([]byte, error) {
	id := r.Header.Get("formance-webhook-id")
	timestampHeader := r.Header.Get("formance-webhook-timestamp")
	signatures := r.Header.Get("formance-webhook-signature")
	if id == "" || timestampHeader == "" || signatures == "" {
		return nil, fmt.Errorf("missing webhook signature headers")
	}

	timestamp, err := strconv.ParseInt(timestampHeader, 10, 64)
	if err != nil {
		return nil, fmt.Errorf("invalid webhook timestamp: %w", err)
	}
	attemptTime := time.Unix(timestamp, 0)
	if age := time.Since(attemptTime); age < -signatureTolerance || age > signatureTolerance {
		return nil, fmt.Errorf("webhook timestamp outside tolerance")
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		return nil, fmt.Errorf("read webhook body: %w", err)
	}
	verified, err := security.Verify(signatures, id, timestamp, secret, body)
	if err != nil {
		return nil, fmt.Errorf("verify webhook signature: %w", err)
	}
	if !verified {
		return nil, fmt.Errorf("invalid webhook signature")
	}

	return body, nil
}

Do not parse and re-serialize the JSON before verification. Any byte-level change produces a different signature. Keep your server clock synchronized so a legitimate request is not rejected by the timestamp check.

Acknowledge after durable acceptance#

Webhooks treats every 2xx response as success. Return success after the event is stored in a system that survives process failure, such as a database-backed inbox or durable queue. If you respond before storing the event and then crash, Webhooks has no reason to retry it.

A typical receiver follows this sequence:

If validation or durable acceptance fails, return a non-2xx status that reflects whether another attempt could succeed.

Webhooks retries 408, 429, 5xx, transport errors, and timeouts. Other 4xx responses are terminal.

Make processing idempotent#

Webhook delivery can be duplicated at the HTTP boundary. A worker can lose its database connection after your endpoint accepted a request but before Webhooks recorded the successful attempt. Recovery can then send the event again.

Create a unique constraint on formance-webhook-id, then record the identifier and the business result in one transaction. If the identifier already exists, return the same successful response without applying the effect again. The delivery ID remains stable across automatic retries.

Manual replay also preserves the delivery ID.

Also make the business operation idempotent from stable domain identifiers in the payload when possible. Use formance-webhook-idempotency-key as additional context when present, but do not rely on it exclusively because producers can leave it empty.

Do not depend on delivery order#

Webhooks dispatches several deliveries concurrently. A retry can also allow a later event to arrive before an earlier one. If order matters, use the event's resource identifiers and timestamps to reconcile state, or fetch the current resource from its source API before applying a transition.

Test an endpoint#

Use the configuration test operation to exercise network access, signature verification, and response handling:

curl -X GET $FORMANCE_API_URL/api/webhooks/configs/{id}/test
GET/api/webhooks/configs/{id}/test

Test requests send {"data":"test"}, set formance-webhook-test: true, and are not retried or stored as durable deliveries. They test network access, signing, and the immediate endpoint response; they do not prove that a specific module event subscription or production payload handler is correct. Validate live event handling in a non-production Stack before enabling business effects.

Rotate a secret#

curl -X PUT $FORMANCE_API_URL/api/webhooks/configs/{id}/secret/change
PUT/api/webhooks/configs/{id}/secret/change

Secret rotation takes effect immediately; Webhooks does not provide a sender-managed dual-secret grace period. Deploy the new secret to your receiver in coordination with the rotation.

The dispatcher reads the current configuration before each attempt, so queued attempts use the new secret after rotation. A request that was already in flight can still carry the previous signature. During a controlled transition, let your receiver temporarily accept both secrets, rotate the Webhooks configuration, then remove the previous secret after in-flight requests have completed.

WebhooksDelivery Lifecycle and Guarantees
On This Page
  • Request contract
  • Verify the signature
  • Acknowledge after durable acceptance
  • Make processing idempotent
  • Do not depend on delivery order
  • Test an endpoint
  • Rotate a secret