Hookbase
The Hookbase .NET SDK provides a typed, async client for sending webhooks to your customers. It targets .NET 8 or newer. Its one dependency is BouncyCastle (BouncyCastle.Cryptography), used for Ed25519 signatures. HTTP uses System.Net.Http.HttpClient. Every method is async and takes an optional CancellationToken as its last argument.
Source: github.com/HookbaseApp/hookbase-dotnet
Installation
dotnet add package Hookbase --version 5.1.0Quick Start
using Hookbase;
using Hookbase.Models.Applications;
using Hookbase.Models.Endpoints;
using Hookbase.Models.EventTypes;
using Hookbase.Models.Messages;
using Hookbase.Models.Subscriptions;
using var client = new HookbaseClient(Environment.GetEnvironmentVariable("HOOKBASE_API_KEY")!);
// An application represents one of your customers
var app = await client.Applications.CreateAsync(new CreateApplicationRequest
{
Name = "Acme Corp",
ExternalId = "customer_123"
});
// The endpoint the customer receives webhooks on
var endpoint = await client.Endpoints.CreateAsync(new CreateEndpointRequest
{
ApplicationId = app.Id,
Url = "https://example.com/webhooks"
});
// An event type, and a subscription from the endpoint to it
var orderCreated = await client.EventTypes.CreateAsync(new CreateEventTypeRequest { Name = "order.created" });
await client.Subscriptions.CreateAsync(new CreateSubscriptionRequest
{
EndpointId = endpoint.Id!,
EventTypeId = orderCreated.Id!
});
// Send a webhook to the application's subscribed endpoints
var sent = await client.Messages.SendAsync(new SendMessageRequest
{
EventType = "order.created",
ApplicationId = app.Id,
Payload = new Dictionary<string, object> { ["orderId"] = "ord_123", ["amount"] = 99.99 }
});
Console.WriteLine($"{sent.EventId} queued to {sent.MessagesQueued} endpoint(s)");Client Configuration
using Hookbase;
// Defaults: https://api.hookbase.app, 30 s timeout, 3 retries
using var client = new HookbaseClient(Environment.GetEnvironmentVariable("HOOKBASE_API_KEY")!);
// Or configure with options
using var configured = new HookbaseClient(new HookbaseClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("HOOKBASE_API_KEY")!,
BaseUrl = "https://api.hookbase.app", // Optional; an absolute http(s) URL
Timeout = 30, // Optional, seconds per attempt
MaxRetries = 3 // Optional retry count
});Create one client per API key and share it. The constructor throws ArgumentException for an empty API key or a BaseUrl that is not an absolute http or https URL, and ArgumentOutOfRangeException for a Timeout of 0 or less. A path in BaseUrl is kept, and request paths are appended to it. Dispose the client when you're done with it, which disposes the HttpClient it created.
Request bodies are records with init properties (CreateEndpointRequest, SendMessageRequest ...). A property you don't set is left out of the request.
Custom HttpClient and IHttpClientFactory
Set HttpClient to send through your own client, for example one from IHttpClientFactory, or one with a logging or proxy handler. The SDK never modifies it: BaseAddress, Timeout and DefaultRequestHeaders stay as you set them. Each request goes to an absolute URL built from BaseUrl, with its own Authorization and User-Agent headers, so one HttpClient can be shared by clients with different API keys. The SDK doesn't dispose a client you supplied.
builder.Services.AddHttpClient("hookbase");
builder.Services.AddSingleton(sp => new HookbaseClient(new HookbaseClientOptions
{
ApiKey = builder.Configuration["Hookbase:ApiKey"]!,
HttpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("hookbase")
}));The effective timeout is the shorter of HookbaseClientOptions.Timeout and the HttpClient's own Timeout (100 seconds unless you change it), so raise both for long requests. A singleton HookbaseClient keeps the HttpClient it was created with, so the factory's handler rotation doesn't reach it. If DNS changes matter, give the named client a SocketsHttpHandler with a PooledConnectionLifetime.
Applications
Applications represent your customers. Each customer should have one application.
Create Application
var app = await client.Applications.CreateAsync(new CreateApplicationRequest
{
Name = "Acme Corp",
ExternalId = "customer_123", // Your internal customer ID
Metadata = new Dictionary<string, object> { ["plan"] = "enterprise" }
});Get or Create Application
// Returns the existing app (updated with these values) or creates a new one
var app = await client.Applications.GetOrCreateAsync(new GetOrCreateApplicationRequest
{
ExternalId = "customer_123",
Name = "Acme Corp"
});GetOrCreateAsync doesn't report whether the application was created (the API's created flag isn't surfaced).
List Applications
// One page
var page = await client.Applications.ListAsync(limit: 50);
// Every application, fetched page by page as you iterate
await foreach (var a in client.Applications.ListAllAsync())
{
Console.WriteLine(a.Name);
}The .NET SDK has no call to get an application by its external ID: use GetOrCreateAsync, or ListAsync(search: ...).
Event Types
Event types define the webhook events your customers can subscribe to.
Create Event Type
var eventType = await client.EventTypes.CreateAsync(new CreateEventTypeRequest
{
Name = "order.created",
DisplayName = "Order Created",
Category = "orders",
Schema = """{"type":"object","required":["orderId"]}""" // JSON Schema as a JSON string
});The .NET SDK can't set an example payload when creating an event type.
List Event Types
using Hookbase.Resources;
var page = await client.EventTypes.ListAsync(limit: 100);
foreach (var t in page.Data)
{
Console.WriteLine(t.Name);
}
// With filters
var orders = await client.EventTypes.ListAsync(new EventTypeListOptions
{
Category = "orders",
IsEnabled = true,
Search = "order"
});Subscriptions
// Subscribe an endpoint to an event type
var sub = await client.Subscriptions.CreateAsync(new CreateSubscriptionRequest
{
EndpointId = "e25aaf14-ecb3-5f7c-9597-225979d06ed9",
EventTypeId = "d14e4a97-4566-51bb-8130-baeae1cbcef8",
FilterExpression = "amount > 100", // Optional payload filter
LabelFilters = new Dictionary<string, object> { ["region"] = "eu" }, // Optional label match
LabelFilterMode = "all" // "all" (default) or "any"
});
// Several at once
var result = await client.Subscriptions.BulkSubscribeAsync(new BulkSubscribeRequest
{
EndpointId = "e25aaf14-ecb3-5f7c-9597-225979d06ed9",
EventTypeIds = new List<string> { "c448e8e1-8501-53a3-be4d-a6278a863484", "b371f66f-f84a-5ee6-ae1d-82ceb50672ab" }
});
Console.WriteLine($"{result.Created} created, {result.Skipped} already subscribed");Messages
Messages are the webhooks you send to your customers.
Send Message
Messages.SendAsync sends to the API key's organization. Set ApplicationId on the request to deliver to one application's endpoints. Without it, the event goes to every endpoint in the organization subscribed to the event type.
var sent = await client.Messages.SendAsync(new SendMessageRequest
{
EventType = "order.created",
ApplicationId = "519c05c7-ada5-5d3e-9b80-2c0dc9743117",
Payload = new Dictionary<string, object> { ["orderId"] = "ord_123" },
Labels = new Dictionary<string, string> { ["region"] = "eu" },
IdempotencyKey = "order-ord_123-created" // Optional
});
Console.WriteLine($"{sent.EventId} queued to {sent.MessagesQueued} endpoint(s)");Every SendAsync carries an idempotency key: the one you set, or a UUID generated for the call. The API deduplicates on it, so a send retried after a timeout is delivered once. Set EndpointIds to deliver to some endpoints only, Priority (0, critical, to 4, bulk) to change its queue priority, and EventVersion to send a specific version of the event type.
SendAsync, ListAsync, GetAsync, ReplayAsync, GetStatsSummaryAsync and ExportAsync also have an overload that takes an organization ID first, such as SendAsync("org_123", request). It calls the organization-scoped routes, and a key from another organization gets a 403. Versions before 5.1.0 had only these overloads.
List Messages
var page = await client.Messages.ListAsync(applicationId: "519c05c7-ada5-5d3e-9b80-2c0dc9743117", status: "failed");
foreach (var m in page.Data)
{
Console.WriteLine($"{m.Id} {m.Status} ({m.Attempts}/{m.MaxAttempts} attempts)");
}
// Each delivery attempt of one message
var attempts = await client.Messages.ListAttemptsAsync("5c588ab6-d167-5c12-8735-fd1df20421b0");
foreach (var a in attempts)
{
Console.WriteLine($"#{a.AttemptNumber} {a.Status} {a.ResponseStatus} {a.ErrorMessage}");
}GetStatsSummaryAsync returns counts by status, and ExportAsync(new MessageExportOptions { Format = "csv" }) returns an export as a string.
Retry Failed Message
// Replays the message (creates a new message)
var replay = await client.Messages.ReplayAsync("5c588ab6-d167-5c12-8735-fd1df20421b0");
Console.WriteLine(replay.NewMessageId);Dead-Letter Queue
Messages that ran out of retries are also listed in the dead-letter queue, client.Dlq:
using Hookbase.Resources;
var dead = await client.Dlq.ListAsync(new DlqListOptions
{
EndpointId = "e25aaf14-ecb3-5f7c-9597-225979d06ed9",
Reason = "max_retries_exceeded",
Limit = 50
});
var retried = await client.Dlq.RetryBulkAsync(dead.Data.Select(m => m.Id!));
Console.WriteLine($"{retried.Queued} queued, {retried.Skipped} skipped");
int deleted = await client.Dlq.DeleteBulkAsync(new[] { "8626c3e1-b83e-560e-9d68-89416df27fcf", "ef435c1f-007c-5d38-91cb-75ea4ee9d36c" });GetStatsAsync, RetryAsync and DeleteAsync handle one message or the totals.
Endpoints
Endpoints are managed by your customers through the Portal, but you can also manage them programmatically.
Create Endpoint
// The signing secret is returned only here; give it to your customer.
var endpoint = await client.Endpoints.CreateAsync(new CreateEndpointRequest
{
ApplicationId = "519c05c7-ada5-5d3e-9b80-2c0dc9743117",
Url = "https://example.com/webhooks",
Description = "Production"
});
var secret = endpoint.Secret;
// An Ed25519 endpoint: Hookbase keeps the private key and returns only the public key
var edEndpoint = await client.Endpoints.CreateAsync(new CreateEndpointRequest
{
ApplicationId = "519c05c7-ada5-5d3e-9b80-2c0dc9743117",
Url = "https://example.com/webhooks",
SignatureScheme = "ed25519"
});
var publicKey = edEndpoint.PublicKey; // whpk_...List Endpoints
using Hookbase.Resources;
var page = await client.Endpoints.ListAsync(applicationId: "519c05c7-ada5-5d3e-9b80-2c0dc9743117");
// With filters
var open = await client.Endpoints.ListAsync(new EndpointListOptions
{
ApplicationId = "519c05c7-ada5-5d3e-9b80-2c0dc9743117",
CircuitState = "open",
IsDisabled = false
});Get Endpoint Statistics
var stats = await client.Endpoints.GetStatsAsync("e25aaf14-ecb3-5f7c-9597-225979d06ed9");
Console.WriteLine($"{stats.SuccessRate:P1} of {stats.TotalMessages} delivered");SuccessRate is a fraction from 0 to 1 (format it with P). GetStatsAsync is computed from the endpoint's own counters (TotalMessages, TotalSuccesses, TotalFailures, AvgResponseTimeMs), which GetAsync also returns.
Rotate the Signing Secret
var rotated = await client.Endpoints.RotateSecretAsync("e25aaf14-ecb3-5f7c-9597-225979d06ed9");
Console.WriteLine($"{rotated.Secret} (old secret valid until {rotated.PreviousSecretExpiresAt})");
// Keep the old secret for 24 hours instead, or pass 0 to retire it immediately
var slow = await client.Endpoints.RotateSecretAsync("e25aaf14-ecb3-5f7c-9597-225979d06ed9", gracePeriodSeconds: 86400);Without a grace period, the previous secret keeps signing for the API's default of one hour. gracePeriodSeconds takes 0 to 86400. For an Ed25519 endpoint, rotated.Secret is null: read rotated.PublicKey, and rotated.PreviousPublicKey for the key that keeps signing during the grace period.
The .NET SDK has no methods to send a test event to an endpoint or to reset its circuit breaker.
Portal Tokens
Portal tokens authenticate the embedded Portal component.
Create Portal Token
var token = await client.PortalTokens.CreateAsync("519c05c7-ada5-5d3e-9b80-2c0dc9743117", new CreatePortalTokenRequest
{
Name = "Customer dashboard",
Scopes = new List<string> { "read", "write" },
ExpiresInDays = 30
});
// Return token.Token to your frontend; it is only returned on create
var plaintext = token.Token;Webhook Verification
Help your customers verify incoming webhooks. Always verify the raw body exactly as received: JSON that was parsed and re-serialized no longer matches the signature.
Verify a Request
using System.Text;
using System.Text.Json;
using Hookbase.Exceptions;
using Hookbase.Webhooks;
var builder = WebApplication.CreateBuilder(args);
var webApp = builder.Build();
var verifier = new WebhookVerifier(Environment.GetEnvironmentVariable("HOOKBASE_WEBHOOK_SECRET")!); // whsec_...
// ASP.NET Core minimal API
webApp.MapPost("/webhooks", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync();
var headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
try
{
var evt = verifier.Verify<JsonElement>(body, headers);
Console.WriteLine($"Event: {evt.GetProperty("type").GetString()}");
return Results.Ok();
}
catch (WebhookVerificationException)
{
return Results.Unauthorized();
}
});
webApp.Run();Verify<T> checks the webhook-id, webhook-timestamp and webhook-signature headers (names are case-insensitive), rejects timestamps more than 5 minutes from now, and accepts any of the space-separated signatures sent during a secret rotation. It returns the body deserialized as T. Pass a TimeSpan as tolerance to change the window, or call VerifySignature, which returns a bool, checks only the signature, and accepts any body, JSON or not.
Read the body as UTF-8 yourself, as above: Hookbase sends application/json without a charset.
WebhookVerifier verifies v1 (HMAC-SHA256) signatures with a whsec_ secret. For endpoints using the ed25519 scheme, pass the endpoint's whpk_ public key instead (new WebhookVerifier(endpoint.PublicKey!)), and Verify checks v1a signatures. A malformed key throws ArgumentException from the constructor. See Ed25519 (v1a) Signatures.
Generate Test Headers
var verifier = new WebhookVerifier("whsec_dGVzdF9zZWNyZXRfa2V5X2Zvcl90ZXN0aW5n");
var payload = """{"orderId":"test_123"}""";
Dictionary<string, string> headers = verifier.GenerateTestHeaders(payload);
// Use these headers to test your webhook handlerA whpk_ public key can't sign, so GenerateTestHeaders throws InvalidOperationException on such a verifier. To produce v1a test headers, create a verifier from an Ed25519 secret key in Standard Webhooks form, whsk_ followed by the base64 of the 32-byte seed and the 32-byte public key (one you generate yourself; Hookbase never returns the private key). A whsk_ verifier can sign and verify:
using System.Text.Json;
using Hookbase.Webhooks;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
var privateKey = new Ed25519PrivateKeyParameters(new SecureRandom());
var seed = privateKey.GetEncoded();
var publicKey = privateKey.GeneratePublicKey().GetEncoded();
var signer = new WebhookVerifier("whsk_" + Convert.ToBase64String([.. seed, .. publicKey]));
var verifier = new WebhookVerifier("whpk_" + Convert.ToBase64String(publicKey));
var payload = """{"orderId":"test_123"}""";
verifier.Verify<JsonElement>(payload, signer.GenerateTestHeaders(payload));Error Handling
Every exception the SDK throws is a HookbaseException, in Hookbase.Exceptions:
| Exception | When |
|---|---|
HookbaseApiException | Any error response: StatusCode, Code, RequestId, Details |
AuthenticationException | 401 |
ForbiddenException | 403, including plan limits and disabled features |
NotFoundException | 404 |
ConflictException | 409 |
ValidationException | 400 / 422, with FieldErrors (field name to messages) |
RateLimitException | 429, with RetryAfter (seconds, when the API sent Retry-After) |
HookbaseTimeoutException | A request that ran past Timeout, after any retries |
HookbaseNetworkException | DNS, connection or TLS failures, after any retries |
WebhookVerificationException | Signature verification failed |
The status-specific exceptions subclass HookbaseApiException. Code is the API's machine-readable error code, such as FEATURE_DISABLED or LIMIT_EXCEEDED (null when the body has none). Match on it rather than on Message. Details holds the other top-level fields of the error body. HookbaseTimeoutException and HookbaseNetworkException subclass HookbaseException and keep the TaskCanceledException or HttpRequestException in InnerException.
using Hookbase.Exceptions;
try
{
await client.Endpoints.GetAsync("invalid_id");
}
catch (NotFoundException)
{
Console.WriteLine("Endpoint not found");
}
catch (RateLimitException e)
{
Console.WriteLine($"Rate limited, retry after {e.RetryAfter}s");
}
catch (ValidationException e)
{
Console.WriteLine($"Invalid: {string.Join(", ", e.FieldErrors?.Keys ?? Enumerable.Empty<string>())}");
}
catch (ConflictException e)
{
Console.WriteLine($"Conflict: {e.Message}");
}
catch (HookbaseApiException e)
{
Console.WriteLine($"API error {e.StatusCode} ({e.Code}): {e.Message} (request {e.RequestId})");
}
catch (HookbaseTimeoutException)
{
Console.WriteLine("Timed out");
}
catch (HookbaseException e)
{
Console.WriteLine($"Request failed: {e.Message}");
}Retries
Failed requests are retried up to MaxRetries times (default 3):
- 429 is retried for every method, after the
Retry-Afterthe API sends (seconds or an HTTP date). Without one, it backs off like a 5xx. ARetry-Afterlonger than 60 seconds, such as a quota window, is not waited out:RateLimitExceptionis thrown at once withRetryAfterset. - 500, 502, 503, 504, timeouts and network errors are retried with exponential backoff (1, 2, 4 ... seconds, at most 10, plus up to 30% jitter, or the
Retry-Afterwhen sent) for every method except POST. A POST is retried only when it carries an idempotency key. Of the outbound calls, that isMessages.SendAsync, which always has one. - Other 4xx responses, including 409, are never retried.
Each attempt gets the full Timeout.
Cancellation
There are no per-call request options. Timeout and retries are set in HookbaseClientOptions, and the idempotency key is a property of SendMessageRequest. Every method takes a CancellationToken:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var app = await client.Applications.GetAsync("519c05c7-ada5-5d3e-9b80-2c0dc9743117", cts.Token);Cancelling the token throws TaskCanceledException (an OperationCanceledException), stops any backoff wait, and is never retried.
Pagination
ListAsync methods return a CursorPage<T>: Data holds the items, and HasMore and NextCursor say whether there is another page. Pass the cursor back to fetch it:
string? cursor = null;
do
{
var page = await client.Endpoints.ListAsync(applicationId: "519c05c7-ada5-5d3e-9b80-2c0dc9743117", limit: 100, cursor: cursor);
foreach (var ep in page.Data)
{
Console.WriteLine(ep.Url);
}
cursor = page.HasMore ? page.NextCursor : null;
} while (cursor != null);Of the outbound resources, only Applications has ListAllAsync, which streams every page as an IAsyncEnumerable<T>. Sources, destinations and events have it on the inbound side.
Models
Responses are immutable records in the Hookbase.Models.* namespaces, named after the API's resources (Application, Endpoint, EventType, Subscription, OutboundMessage, PortalToken, Source, Event, Delivery ...). Property names are the API's JSON names in Pascal case. Most timestamps are ISO-8601 strings, and free-form JSON such as payloads is a Dictionary<string, object> or JsonElement.
The SDK also covers the inbound API (sources, destinations, routes, events, deliveries, transforms, filters, schemas, provider catalog) and admin resources (analytics, API keys, tunnels, cron jobs), following the same pattern. See the README and examples for more.