Webhook authentication for Acumatica ERP. PX.Api.Webhooks.IWebhookHandler hands you a
request and leaves authentication entirely to you. The first sample is Bearer JWT (bh +
aud = webhook id). HMAC-over-body (GitHub / Shopify / Stripe presets) is the other family —
same base class, admin-managed secrets in the ERP database.
public class PushEventHandler : AuthenticatedWebhookHandlerBase
{
protected override IWebhookAuthenticator CreateAuthenticator(IWebhookSecretProvider secrets) =>
new JwtAuthenticator(WebhookAuthPresets.JwtBearer(secrets));
protected override Task ProcessAsync(AuthenticatedWebhookContext context, CancellationToken cancellation)
{
// context.Body is the request body. JWT binds those bytes via claim `bh`.
}
}That's a complete, authenticated Bearer JWT webhook (bh + aud = webhook id). HMAC senders
use WebhookAuthPresets.GitHub / Shopify / Stripe instead. The base class reads the body once into a
bounded buffer, verifies against it, answers every failure with the same generic 401, and hands
the body buffer — never the spent stream — to your code. The secret lives in the ERP
database, maintained by an administrator on its own screen.
- Schemes — HMAC over the request body, HMAC with replay window, compact JWT (HS256/HS512; token HMAC, not body HMAC), shared secret, HTTP Basic, explicit none; presets for GitHub, Shopify, Stripe and Bearer JWT, plus a template language for other HMAC senders
- Secrets managed in the ERP — encrypted
[PXRSACryptString]storage, a Modern UI maintenance screen (AS301000), per-webhook secrets, edits live within 30 seconds, no restart - Zero-downtime secret rotation — old and new secrets accepted until the overlap you set expires
- Per-webhook IP allowlists — IPv4/IPv6 CIDR, admin-configurable, for deployments behind a trusted proxy
- Security first — constant-time comparison, fail-closed on missing secrets,
indistinguishable 401s (diagnostics go to
PXTraceonly), verified-bytes-only processing - A signature debugger —
WebhookSignatureTestershows what was signed, what was expected and what arrived, ending the guess-why-it-401s hour every integration starts with
GitHub Releases attach the Core nupkg. Ubuntu CI does not attach
AISI.WebhookAuthenticator.zip — the runner has no Acumatica site Bin, so release.yml
skips the zip rather than shipping a missing adapter. Pack the zip locally against a 2025 R2+
Bin (AcumaticaBinPath).
- Core — take
AISI.AcumaticaWebhookAuthenticator.Corefrom the latest release (or buildsrc/AISI.AcumaticaWebhookAuthenticator.Core; no site required). - Customization zip — pack
AISI.WebhookAuthenticator.zipon a machine with a 25R2+ site Bin. Import and publish it on SM204505: secrets table, Webhook Secrets screen, Modern UI. Do not expect that zip on a GitHub Release built by ubuntu-latest. - Write a handler like the one above, referencing Core (nupkg) plus the adapter when you publish on the site.
- Register the webhook on SM304000 with your handler's type name.
- Enter the secret on AS301000. Requests that don't verify never reach your code.
Note: the packaged site map uses Modern UI as the default (
SelectedUI=D,~/Scripts/Screens/AS301000.html). See the package notes.
git clone https://github.com/AISI-Dev-Co/AISI.AcumaticaWebhookAuthenticator
cd AISI.AcumaticaWebhookAuthenticator
dotnet build -c Release -p:AcumaticaBinPath="C:\AcumaticaSites\MySite\Bin"AcumaticaBinPath (or the ACUMATICA_BIN environment variable) points the Acumatica adapter at
a local 2025 R2+ site's Bin for its licensed PX.* references — they are never copied into any
output or package. The platform-agnostic core needs no site at all:
dotnet build src/AISI.AcumaticaWebhookAuthenticator.Core -c Release| Code | Type | What authenticates |
|---|---|---|
HMAC |
HmacAuthenticator |
HMAC signature over a templated payload |
HMACTS |
HmacAuthenticator with Timestamp |
the same, inside a replay window |
SECRET |
SharedSecretAuthenticator |
the shared secret itself in a header |
BASIC |
BasicAuthenticator |
RFC 7617 Authorization: Basic |
NONE |
NoneAuthenticator.Instance |
nothing — an explicit, recorded decision |
JWT |
JwtAuthenticator |
Compact JWS (HS256 / HS512) over the token, plus required bh body-hash claim |
SECRET and BASIC credentials are not bound to the request: anyone who observes one can replay
it against any payload. They exist for senders that offer nothing better — prefer a body-HMAC
scheme (GitHub / Shopify / Stripe / HmacAuthenticator) whenever the sender supports it. For
BASIC the stored secret is the whole user:password string, and the 401 carries the RFC 7235
WWW-Authenticate challenge.
JWT is not body-HMAC. Compact JWS HMAC covers header.payload only (RFC 7515). Without a
body-hash claim that is the same unbound credential as SECRET/BASIC: a captured token
authenticates any body. This library defaults to requiring claim bh (base64url SHA-256 of the
raw HTTP body, compared constant-time) and aud equal to the webhook registration id, so a
reused secret cannot be presented to a different webhook. exp is required unless you turn that
off; iss is checked only when configured. RS256 is not implemented — that would pull
Microsoft.IdentityModel.* into the site Bin.
protected override IWebhookAuthenticator CreateAuthenticator(IWebhookSecretProvider secrets) =>
new JwtAuthenticator(WebhookAuthPresets.JwtBearer(secrets));JwtBearer(secrets) keeps RequireBodyHash and BindAudienceToWebhookId on (claim bh + aud = webhook id). Do not start from a snippet that sets both to false — that is an unbound bearer, same class as SECRET/BASIC.
JwtBearer(secrets, audience) sets Audience only. It does not turn off BindAudienceToWebhookId. When Audience is set, that value wins over the webhook registration id.
| Preset | Header | Encoding | Signs |
|---|---|---|---|
WebhookAuthPresets.GitHub |
X-Hub-Signature-256 |
hex, sha256= prefix |
body |
WebhookAuthPresets.Shopify |
X-Shopify-Hmac-Sha256 |
base64 | body |
WebhookAuthPresets.Stripe |
Stripe-Signature |
hex, t=/v1= list |
{timestamp}.{body} |
WebhookAuthPresets.JwtBearer |
Authorization: Bearer |
JWT compact, HS256 | Not the HTTP body. Signs the JWT (header.payload). Body is bound only via required bh (SHA-256 of the raw body). Without bh this is an unbound bearer credential, like SECRET/BASIC. |
Everything else is expressible with HmacAuthOptions:
var options = new HmacAuthOptions(secretProvider, signatureHeader: "X-Signature")
{
Algorithm = HmacAlgorithm.Sha256, // Sha1, Sha256, Sha512
Encoding = SignatureEncoding.Base64, // Hex, Base64
SignaturePrefix = "v1=",
Extraction = SignatureExtraction.Whole, // or KeyValueElement("v1") for compound headers
Template = SignedPayloadTemplate.Parse("{method}\n{timestamp}\n{body}"),
Timestamp = TimestampValidation.FromHeader("X-Timestamp", TimeSpan.FromMinutes(5)),
};Template tokens: {body}, {timestamp}, {method}, {path}, {header:Name}; literal braces
are {{ and }}. SignatureExtraction.KeyValueElement("v1") pulls signatures out of compound
headers like Stripe's t=1614556800,v1=5257a8… and tries every matching element.
Misconfigurations throw when the authenticator is constructed, not as puzzling 401s in
production: a replay window over a timestamp the template doesn't sign, an undefined enum value,
or — under Acumatica, which exposes no request path — a {path} template.
Build options at startup, construct the authenticator, discard the options: HmacAuthOptions is
a mutable builder that the authenticator snapshots at construction and never reads again. The
authenticator itself is immutable and safe to share.
Secrets live in the AISIWebhookSecret table (schema), one row per
webhook registration, encrypted with [PXRSACryptString] — Acumatica's own pattern for
integration credentials, and the only one that works on SaaS. AuthenticatedWebhookHandlerBase
wires this up automatically; override CreateSecretProvider to source secrets elsewhere, or use
StaticSecretProvider in tests (never in production).
Rotation is first-class — a sender mid-rotation signs with either secret until the overlap closes:
WebhookSecret secret = WebhookSecret
.FromUtf8(current)
.WithRotatingUtf8(previous, expiresOn: DateTimeOffset.UtcNow.AddDays(7));On the screen that's just the Rotating Secret and Rotation Ends (UTC) columns. Key material never
leaves WebhookSecret; verification happens inside it.
Encryption at rest requires a site certificate. Without one,
[PXRSACryptString]degrades to base64 obfuscation. Configure an encryption certificate (SM200530) on any instance whose database backups matter.
Set Allowed IP Addresses (203.0.113.0/24, 2001:db8::/32), Client Address Header and
Trusted Proxy Depth on the Webhook Secrets screen — validated on save, applied automatically,
live within 30 seconds. An unparseable stored list denies everything rather than restricting
nothing. The same gate is available in code:
new IpAllowlistAuthenticator(
new HmacAuthenticator(WebhookAuthPresets.GitHub(secrets)),
IpAllowlist.Parse("203.0.113.0/24", "2001:db8::/32"));Read before deploying: Acumatica exposes no remote address, so the caller's IP comes from a forwarded header — which any sender can write. The gate is only meaningful behind a trusted front proxy that controls that header. The client address is read
trustedProxyDepthentries from the right; everything left of that is the sender's to invent and is ignored. This is defence in depth on top of a signature scheme, not authentication.
SignatureTestReport report = WebhookSignatureTester.Test(options, capturedRequest);
report.SignedPayloadPreview; // "1614556800.{\"id\":\"evt_1\"}"
report.ExpectedSignatures; // current secret first, then rotating if the overlap is live
report.ProvidedSignatures; // what the sender sent
report.FailureCode; // e.g. "signature_prefix_mismatch"The report contains expected signatures — never return it in an HTTP response.
| Acumatica | 2025 R2 – 2026 R1 (PX.Api.Webhooks.IWebhookHandler) |
| Core | netstandard2.0 — no Acumatica or ASP.NET references |
| Adapter | net48, compiled against both ends of the support matrix |
The core serves today's net48 runtime and .NET 8+ after Acumatica's migration without a re-target. Every platform behavior the adapter relies on is verified against the decompiled assemblies at both supported versions — the receipts are in docs/framework-notes.md.
- The signed payload is bytes. Templates resolve to
byte[]with the raw body spliced in verbatim — never round-tripped through a string, which is lossy for BOMs, charsets and invalid sequences. - Failures are indistinguishable. One 401, one generic body;
AuthFailureCodegoes to the trace, never to the sender — a caller who can tell "malformed" from "wrong" has an oracle. - Fail closed. No secret, unparseable allowlist, over-limit body: denied. Nothing ever degrades to unauthenticated handling.
- Constant time. Every secret comparison goes through a fixed-time comparer, never short-circuited across rotation candidates.
- Retries — redelivery handling for payloads whose processing failed after authenticating
- Full payload capture to Acumatica's webhook request record, so the platform's built-in request log carries the complete verified body
- nuget.org publication on version tags — add a
NUGET_API_KEYActions secret to enable the push (the nupkg is already a Release asset)
Need this published on a SaaS tenant, wired to a live sender, or extended past this scope? AISI Dev Co does scoped Acumatica customisation for VARs.
dotnet test tests/AISI.AcumaticaWebhookAuthenticator.Core.TestsNo Acumatica instance required — everything testable lives in the core, and CI builds and tests
exactly that. New signature schemes need a known-good and a known-bad vector in
SenderVectorTests, preferably published by the sender (the GitHub pair comes from GitHub's own
docs). Timing safety is asserted structurally; wall-clock timing tests flake in CI and get
deleted.