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 bodyIWebhookIdempotencyStore— drop duplicate retriesHttpRequest.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 eventfalse— 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
- Outbound Webhook Registration
- External Device Id — for the
FindByExternalIdAsynclookup