Skip to main content

Inbound Webhooks

Third-party services push state changes to BT via signed HTTP webhooks. The SDK provides three composable primitives:

  • IWebhookSignatureScheme — verify the third party signed the body
  • IWebhookIdempotencyStore — drop duplicate retries
  • HttpRequest.ReadAndBufferBodyAsync() — capture raw bytes the way the third party sent them

Adapters compose these in a controller and dispatch the verified events into the SDK pipeline.

Anatomy of a webhook controller

[ApiController]
[Route("api/{adapter}/webhooks")]
public sealed class EnodeWebhookController(
IWebhookSignatureScheme signature,
IWebhookIdempotencyStore idempotency,
IPropertiesClient properties,
IDeviceRepository devices,
IOptions<EnodeOptions> options) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Receive(CancellationToken ct)
{
// 1. Capture raw bytes — must be exactly what the third party signed.
var body = await Request.ReadAndBufferBodyAsync(ct: ct);

// 2. Verify the signature.
var secret = Encoding.UTF8.GetBytes(options.Value.WebhookSecret!);
if (!signature.Verify(body, Request.Headers, secret, out var failure))
return Unauthorized(new { error = failure });

// 3. Parse + idempotency check + dispatch.
var evt = JsonNode.Parse(body);
var eventId = evt?["id"]?.GetValue<string>();
if (eventId is not null
&& !await idempotency.TryReserveAsync(eventId, ct))
{
return Ok(); // already processed; respond success so the
// third party stops retrying
}

var externalId = evt?["data"]?["id"]?.GetValue<string>();
if (externalId is null) return Ok();
var device = await devices.FindByExternalIdAsync(externalId, ct);
if (device is null) return Ok(); // no BT side yet — skip

properties.Report(device.DeviceId, (JsonObject)evt!["data"]!);
return Ok();
}
}

Signature schemes

The default HmacSha256TimestampScheme covers Stripe-style headers:

X-Webhook-Signature: t=<unix>,v1=<hex-hmac>

where v1 is HMAC-SHA256(secret, "<t>." + body). Replay window is configurable (default 5 minutes).

Vendors with custom shapes implement their own IWebhookSignatureScheme and register it instead:

builder.Services.AddSingleton<IWebhookSignatureScheme, MyVendorScheme>();

The contract is intentionally narrow:

bool Verify(
ReadOnlySpan<byte> body,
IHeaderDictionary headers,
ReadOnlySpan<byte> secret,
out string failure);

Implementations MUST use timing-safe comparison (CryptographicOperations.FixedTimeEquals).

Idempotency

InMemoryWebhookIdempotencyStore is registered by default — TTL 1 hour, capacity-capped. For multi-instance providers register a Redis-backed implementation instead (one is not shipped in v1).

TryReserveAsync returns:

  • true — first sighting; caller MUST process the event
  • false — already seen; caller MUST treat as duplicate (return 200, do not re-process)

Per the third-party retry semantics: returning a 4xx/5xx tells them to retry, returning 200 to a duplicate event id means "yes I have this".

Body capture

HttpRequest.ReadAndBufferBodyAsync() enables ASP.NET request buffering, reads the body to a byte array, and rewinds the body stream so subsequent JSON parsing still works. Do not call Request.ReadFromJsonAsync() before signature verification — that consumes the bytes the third party signed.

Default cap is 1 MB; pass maxBytes: for larger payloads.

See also