Webhooks System
Hub includes a webhook processing system built on an adapter pattern. Webhook records are persisted in MongoDB and processed asynchronously in configurable batches. The system supports hub-to-hub synchronization via the built-in OidisHook adapter.
Architecture
WebhooksManager (orchestrator)
├── RetryingWebhooksManager — Opt-in queue with retries and backoff
├── WebhooksManagerConnector — @Extern API for GUI control
├── BaseWebhookAdapter — Adapter interface (plugin point)
│ ├── OidisHookAdapter — Built-in hub-to-hub sync
│ └── BaseInboundWebhookAdapter — Webhooks other systems send (admission, delivery storage)
├── Inbound/ — InboundWebhookGuard, auth methods, token endpoint
└── WebhookRecord — DB queue (MongoDB model)
Components
| Component | File | Role |
|---|---|---|
WebhooksManager | Webhooks/WebhooksManager.ts | Singleton orchestrator — manages adapters, processing loop, and master stop |
WebhooksManagerConnector | Same file | @Extern connector exposing control methods to the frontend |
BaseWebhookAdapter | Webhooks/Adapters/BaseWebhookAdapter.ts | Abstract base class for webhook adapters |
OidisHookAdapter | Webhooks/Adapters/OidisHook/ | Built-in adapter for hub-to-hub synchronization |
Adapter Pattern
Webhook adapters are registered globally via the @WebhookAdapter decorator:
@WebhookAdapter({ type: "myAdapter" })
export class MyAdapter extends BaseWebhookAdapter {
public IsEnabled() : boolean {
return Loader.getInstance().getAppConfiguration().myWebhook.enabled;
}
public async Process($record : WebhookRecord) : Promise<void> {
// deliver webhook to target system
}
}
Adapters are discovered at runtime from globalThis.oidisWebhookAdapters and instantiated by the WebhooksManager. Only adapters where IsEnabled() returns true are registered.
Each WebhookRecord carries an Adapter field that routes it to the correct adapter. If no matching adapter is found, or if multiple adapters match, the record processing throws an error.
Direction
An adapter either delivers webhooks to another system (outbound, e.g. OidisHook) or processes webhooks received
from one (inbound). Declare it in the decorator; an adapter without direction is outbound:
@WebhookAdapter({direction: WebhookDirection.INBOUND, type: "karatWebhook"})
export class KaratWebhookAdapter extends BaseWebhookAdapter { /* ... */ }
WebhookDirection lives in io-oidis-hub-gui (Connectors/WebhooksManagerConnector.ts), next to the other types shared
by the backend and the dashboard. BaseWebhookAdapter.getDirection() returns it, getWebhooksInfo() reports it for every
adapter and WebhooksManager.getAdapterTypes($direction) lists the types of all declared adapters of a direction, enabled
or not — the dashboard filter uses it to select records by their Adapter.
Processing Loop
The WebhooksManager runs an async processing loop similar to MailEngine:
const wm = Loader.getInstance().getWebhooksManager();
wm.StartLoop(); // Start with configured tick
wm.StartLoop(10000); // Start with custom interval (ms)
wm.StopLoop(); // Stop processing
Loop Behavior
- Start — Checks
webhooks.enabledconfig andmasterStopActiveflag - Each tick — Queries active, non-deleted
WebhookRecorddocuments (limited bybucketSize) - Per record — Routes to matching adapter, calls
adapter.Process(), removes record on success, deactivates on failure and stores the error message inLastError - Auto-stop — Loop stops when no records remain (both processed and inactive)
- Auto-restart —
NotifyWebhookReceived()restarts the loop when new records arrive
Loop States
| State | Meaning |
|---|---|
Stopped | Loop is not running. Restarts when new records arrive |
Running | Currently processing a batch |
Idle | Waiting for next tick |
GUI Control API
The WebhooksManagerConnector exposes these @Extern methods for frontend control:
| Method | Purpose |
|---|---|
getWebhooksInfo() | Returns current loop state, adapter status, and statistics |
setAdapterState($type, $active) | Enable/disable a specific adapter |
ReactivateAdapterRecords($type) | Re-activate failed records for retry |
StartLoop($tick?) | Start the processing loop |
StopLoop() | Stop the processing loop |
MasterStop($active) | Emergency stop — deactivates all records and prevents loop restart |
Dashboard Records Table
EnvironmentHelper.getEventLoopRecords(WEBHOOK_RECORD, $filter, $pending, $direction) feeds the records table of the
Webhooks dashboard panel. By default it lists the inactive (dead-letter) records, $pending = true the queue waiting
for the loop (the Show pending queue switch), and $direction narrows the list to the records of adapters with that
WebhookDirection (the All / Inbound / Outbound buttons). Every record carries adapter and direction, and the
failure fields of WebhookRecord — LastError, Attempts, NextAttemptAt — are shown in the Reason column as
reason, attempts and nextAttempt; RetryingWebhooksManager fills them. Inbound records also show deliveryId,
remoteIp, authMethod and processingTimeMs. Activate on a record calls WebhookRecord.Reactivate() and starts the loop.
Master Stop
MasterStop(true) is an emergency mechanism that:
- Sets
masterStopActiveflag — preventsStartLoop()from executing - Stops the current loop
- Deactivates all active records in batches
MasterStop(false) clears the flag, allowing the loop to restart.
OidisHook Adapter
The built-in OidisHookAdapter enables bidirectional synchronization between Oidis Hub instances:
| Config | Purpose |
|---|---|
oidishook.enabled | Enable/disable the adapter |
oidishook.server.hostname | Remote hub URL |
oidishook.server.userName | Authentication username |
oidishook.server.token | Authentication token |
oidishook.bidirectionalSync | Enable two-way sync |
oidishook.strongValidation | Validate webhook source strictly |
Retrying Queue
The base loop deactivates a record after its first failure. RetryingWebhooksManager (Webhooks/RetryingWebhooksManager.ts)
retries it with a backoff instead and dead-letters it only when the attempts run out, the record outlives
maxQueueAgeHours, or the adapter throws WebhookRejectedError (a failure no retry can fix). An application opts in
from its Loader:
protected initWebhooksManager() : WebhooksManager {
return new RetryingWebhooksManager();
}
Config (webhooks) | Default | Meaning |
|---|---|---|
maxAttempts | 5 | Attempts of one record before it is dead-lettered |
retryBackoffSec | [60, 300, 900, 3600] | Delay before each retry; the last one repeats |
maxQueueAgeHours | 48 | How long an untouched record may wait |
A processed record is soft-deleted (Deleted, ProcessedAt, ProcessingTimeMs kept as history). Reactivating a
dead-lettered record (dashboard, ReactivateAdapterRecords) calls WebhookRecord.Reactivate(), which restarts the retry
budget and the queue age. An application keeping its own WebhookRecord subclass overrides getRecordModel(), so the
records stored under that class stay in the queue.
Inbound Webhooks
An inbound adapter receives webhooks another system sends. It extends BaseInboundWebhookAdapter
(Webhooks/Adapters/BaseInboundWebhookAdapter.ts), declares its REST route on its own Webhook override and returns
the same path from getRoute():
@WebhookAdapter({direction: WebhookDirection.INBOUND, type: "jiraWebhook"})
export class JiraWebhookAdapter extends BaseInboundWebhookAdapter {
public static readonly ROUTE : string = "/webhooks/jira"; // served as POST /api/webhooks/jira
@Route(`POST ${JiraWebhookAdapter.ROUTE}`, {responseType: ExternResponseType.FULL_CONTROL})
public async Webhook($data : unknown, $response : RESTResponse) : Promise<void> {
return await super.Webhook($data, $response); // validate the body first, answer 400 when it is not yours
}
public getRoute() : string { return JiraWebhookAdapter.ROUTE; }
public getServiceAccount() : string { return "jira-webhook"; }
public getSignatureHeader() : string { return "x-hub-signature"; }
protected async registerWebhookRecord($data : any, $headers : ArrayList<string>) : Promise<IInboundWebhookReceipt> {
return await this.storeDelivery({deliveryId: $headers.getItem("x-atlassian-webhook-identifier"),
type: $data.webhookEvent, code: $data.issue?.key, payload: JSON.stringify($data)}, $headers);
}
public async Process($record : WebhookRecord) : Promise<void> {
// runs in the processing loop; throw to retry, throw WebhookRejectedError to dead-letter
}
}
Admission
The Hub RESTResolver admits the caller before the adapter runs. It looks the adapter up by the route
(WebhooksManager.FindInboundAdapter) and asks its InboundWebhookGuard:
- Caller address — the socket address, or the header named by
trustedIpHeaderwhen a reverse proxy sits in front (only the header the own proxy overwrites can be trusted; neverX-Forwarded-For). - IP filter —
ipFilter.allowedRanges(IP addresses or CIDR,IpAllowlist); an enabled filter without ranges does not restrict. - Credentials — every token of the adapter's service account is a valid secret:
| Method | Request | Note |
|---|---|---|
hmac | <signature header>: [sha256=]<hex HMAC-SHA256 of the raw body> | Only when getSignatureHeader() is set; a signature, when present, always decides |
standard-webhooks | webhook-id, webhook-timestamp, webhook-signature: v1,<base64 HMAC-SHA256 of "<id>.<timestamp>.<raw body>"> | Always on; also a signature. Keyed by a token of the account or by a secret the sender issued (signingSecrets) |
basic | Authorization: Basic <account:token> | |
bearer | Authorization: Bearer <token> | |
oauth | Authorization: Bearer <access token> | Only when IsAccessTokenEnabled(); tokens from the token endpoint |
StandardWebhooksAuth (Webhooks/Inbound/Auth/StandardWebhooksAuth.ts) follows the
Standard Webhooks specification, used e.g. by the GitLab signing token. Such senders
generate the secret themselves (whsec_<base64 key>, the key is the decoded part), so it cannot be a token of the
service account: it goes to signingSecrets of the adapter configuration (private configuration), and a valid signature
opens the session with the account's own token. A timestamp more than 5 minutes off the clock is refused, which stops
replays. Which methods count as a signature (deciding alone) is BaseInboundAuth.IsSignature().
An admitted request continues as a session of the service account (so the usual authorization of the Webhook method
applies — grant it to the account's group), with the verified caller in InboundWebhookContext.REMOTE_IP_HEADER and
AUTH_METHOD_HEADER. A refused one gets 401/403 with {"status": "rejected", "code": "invalid_credentials" | "invalid_signature" | "ip_not_allowed" | "internal_error"}.
The application creates the service account and grants the route:
this.addSystemPermissions("JiraWebhook", ...this.generateAuthMethodsFrom<JiraWebhookAdapter>(JiraWebhookAdapter,
(proto) => [proto.Webhook]));
this.addServiceAccount("JiraWebhook", {
group: "JiraWebhook",
token: {enabled: true, name: "jira-webhook-token", noExpiration: true},
user : "jira-webhook"
});
Storing a delivery
storeDelivery() saves the WebhookRecord with Payload, DeliveryId, RemoteIp and AuthMethod and acknowledges
{"status": "accepted", "deliveryId": ...}. A delivery id stored before is acknowledged as "duplicate" without a
second record, so the retries of the sender are processed once.
Token endpoint
POST /api/oauth/token (Webhooks/Inbound/WebhookTokenEndpoint.ts, OAuth 2.0 client credentials) trades Basic
credentials of the service account of an adapter with IsAccessTokenEnabled() for a one-hour access token
(AuthToken named oauth:<account>-<time>). The application grants WebhookTokenEndpoint.Token to the account's
group. Without such an adapter the endpoint issues nothing.
Adapter configuration
Kept under the adapter type (IInboundWebhookConfig):
jiraWebhook: {
enabled: true,
trustedIpHeader: "x-real-ip", // empty = socket address (no proxy in front)
ipFilter: {
enabled: false,
allowedRanges: [] // e.g. ["185.166.140.0/22"]
},
signingSecrets: [] // Standard Webhooks secrets the sender issued ("whsec_..."); private config only
}
The IP filter is edited in Dashboard → Webhooks → Inbound IP filters (one row per inbound adapter with an ipFilter
in its config). It is stored in the application settings (AppSettings.WebhookIpFilters, a WebhookIpFilter per adapter),
applied to the running configuration on save and on start (AppSettings.SyncTo), and seeded from the config for an adapter
that has no stored filter yet (AppSettings.CompleteWebhookIpFilters). The App settings panel does not show it.
trustedIpHeader stays in the config: it depends on the deployment, not on the operator.
Users: EMA (karatWebhook, HMAC + Basic + Bearer + access tokens), Labs (jiraWebhook, HMAC; gitlabWebhook,
Standard Webhooks signing token or Bearer custom header).
Configuration
webhooks: {
enabled : true,
tick : 5000, // processing interval in ms
bucketSize: 50, // max records per batch
maxAttempts: 5, // RetryingWebhooksManager only
retryBackoffSec: [60, 300, 900, 3600], // RetryingWebhooksManager only
maxQueueAgeHours: 48 // RetryingWebhooksManager only
},
oidishook: {
enabled : false,
server : { hostname: "", userName: "", token: "" },
bidirectionalSync : false,
strongValidation : true
}
See Hub Configuration for the full reference.
See also: Hub Configuration | Hub Database Models | Hub Backend Overview