Skip to main content

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​

ComponentFileRole
WebhooksManagerWebhooks/WebhooksManager.tsSingleton orchestrator — manages adapters, processing loop, and master stop
WebhooksManagerConnectorSame file@Extern connector exposing control methods to the frontend
BaseWebhookAdapterWebhooks/Adapters/BaseWebhookAdapter.tsAbstract base class for webhook adapters
OidisHookAdapterWebhooks/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​

  1. Start — Checks webhooks.enabled config and masterStopActive flag
  2. Each tick — Queries active, non-deleted WebhookRecord documents (limited by bucketSize)
  3. Per record — Routes to matching adapter, calls adapter.Process(), removes record on success, deactivates on failure and stores the error message in LastError
  4. Auto-stop — Loop stops when no records remain (both processed and inactive)
  5. Auto-restart — NotifyWebhookReceived() restarts the loop when new records arrive

Loop States​

StateMeaning
StoppedLoop is not running. Restarts when new records arrive
RunningCurrently processing a batch
IdleWaiting for next tick

GUI Control API​

The WebhooksManagerConnector exposes these @Extern methods for frontend control:

MethodPurpose
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:

  1. Sets masterStopActive flag — prevents StartLoop() from executing
  2. Stops the current loop
  3. 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:

ConfigPurpose
oidishook.enabledEnable/disable the adapter
oidishook.server.hostnameRemote hub URL
oidishook.server.userNameAuthentication username
oidishook.server.tokenAuthentication token
oidishook.bidirectionalSyncEnable two-way sync
oidishook.strongValidationValidate 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)DefaultMeaning
maxAttempts5Attempts of one record before it is dead-lettered
retryBackoffSec[60, 300, 900, 3600]Delay before each retry; the last one repeats
maxQueueAgeHours48How 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:

  1. Caller address — the socket address, or the header named by trustedIpHeader when a reverse proxy sits in front (only the header the own proxy overwrites can be trusted; never X-Forwarded-For).
  2. IP filter — ipFilter.allowedRanges (IP addresses or CIDR, IpAllowlist); an enabled filter without ranges does not restrict.
  3. Credentials — every token of the adapter's service account is a valid secret:
MethodRequestNote
hmac<signature header>: [sha256=]<hex HMAC-SHA256 of the raw body>Only when getSignatureHeader() is set; a signature, when present, always decides
standard-webhookswebhook-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)
basicAuthorization: Basic <account:token>
bearerAuthorization: Bearer <token>
oauthAuthorization: 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