Audience: Authors of webhook receivers — Slack / Teams / Discord adapters, Make.com / Zapier flows, custom HTTP endpoints, and any consumer that validates the
eventfield against an enum. Mirror: webhook_schema-tr.md
This is the canonical, exhaustive description of what WebhookNotifier puts on the wire. It is written so a receiver author can implement against it without reading forgelm/webhook.py. Every statement below was verified against the shipped notifier, not against a design note.
Two neighbouring documents overlap deliberately and are narrower on purpose: audit_event_catalog.md maps each webhook event to its audit-log counterpart for correlation, and logging-observability.md states the contributor-facing rules for adding an event. When any of the three disagrees with the code, the code wins and this file is the one to correct first.
One HTTP POST per event. There is no batching, no envelope wrapper, and no event-stream framing — the request body is the payload object described below.
| Property | Value |
|---|---|
| Method | POST |
Content-Type |
application/json |
| Body | json.dumps(payload) — the object described under Always-present fields |
| Destination | webhook.url, falling back to os.getenv(webhook.url_env) when url is unset |
| Timeout | webhook.timeout (default 10 seconds), clamped up to a 1 second floor with a WARNING when lower |
| TLS | Verified against certifi, or against webhook.tls_ca_bundle when set |
| Scheme | https:// recommended. http:// is permitted and logs a WARNING; set webhook.require_https: true to make cleartext a hard refusal instead |
| SSRF policy | Private / loopback / link-local destinations are refused unless webhook.allow_private_destinations: true |
Every delivery goes through the single outbound chokepoint forgelm._http.safe_post, which owns SSRF resolution, redirect policy, and IP pinning.
When nothing is sent. If neither webhook.url nor webhook.url_env resolves to a value, the notifier is a silent no-op: no POST, no error, no log line at WARNING or above. A receiver cannot distinguish "the operator disabled webhooks" from "the operator misconfigured the env var name" by observing the wire — only the training run's own logs show that.
Exactly eight webhook events exist. A receiver must expect no others from a shipped ForgeLM.
event |
Fires when | Gated by | status |
Attachment color |
|---|---|---|---|---|
training.start |
A single-stage run entered train(), before model load. |
webhook.notify_on_start |
started |
#0052cc |
training.success |
The run completed without a revert and without a pending approval. | webhook.notify_on_success |
succeeded |
#36a64f |
training.failure |
Training itself raised — OOM, dataset error, unhandled exception. | webhook.notify_on_failure |
failed |
#ff0000 |
training.reverted |
Training succeeded, then a post-training gate (evaluation, safety, judge, or benchmark) rejected the run and the adapters were deleted. | webhook.notify_on_failure |
reverted |
#ff9900 |
approval.required |
The run succeeded, evaluation.require_human_approval: true, and the model is staged for reviewer sign-off (EU AI Act Art. 14). |
webhook.notify_on_success |
awaiting_approval |
#f2c744 |
pipeline.started |
A fresh multi-stage pipeline run begins, before any stage executes. Not emitted on --resume-from — see When these events do not fire. |
webhook.notify_on_start |
started |
#0052cc |
pipeline.completed |
A multi-stage pipeline run reaches completed or stopped_at_stage. Not emitted when the run ends gated_pending_approval — see When these events do not fire. |
webhook.notify_on_success when final_status == "completed", otherwise webhook.notify_on_failure |
equals final_status |
#36a64f on success, #cc0000 otherwise |
pipeline.stage_reverted |
A stage auto-reverts, emitted at that moment rather than at the end of the run. | webhook.notify_on_failure |
reverted |
#ff9900 |
Four points a receiver author usually gets wrong:
training.successdoes not mean every gate passed. Withevaluation.auto_revert: trueit does. With the shipped defaultauto_revert: false, it also fires when a gate failed, was recorded, and the model was promoted anyway. If your dashboard treats this event as "quality verified", readmetricsrather than the event name.approval.requiredis a pause, not a failure. A receiver that auto-pages ontraining.failuremust not page on this. It is gated bynotify_on_successon purpose: an operator who silenced success notifications does not want approval pings either.pipeline.*events are emitted alongside the per-stagetraining.*events, not instead of them. Each stage'sForgeTrainerstill fires its own lifecycle events, so a pre-existing dashboard filtering ontraining.failurekeeps working unchanged when its operator adopts a pipeline config.pipeline.completedcollides by name with an audit-log event of the same identifier. This is a known wire/audit collision. Correlate on the payload field set, never on the name alone.
Webhook events are not appended to audit_log.jsonl — they ride a best-effort side channel. To correlate a ping with the regulatory record, join on run_name and the event name.
There is no timestamp on the wire. No payload carries one — not under any key, on any event. Use your receiver's own time of arrival if you need one, and treat it as approximate: delivery is best-effort and unordered, so arrival time is not emission time. The audit log is the timestamped record.
pipeline.started and pipeline.completed read as unconditional bookends, but two deterministic paths in the orchestrator skip them. Together they are exactly the enterprise approval-gate flow, so a receiver that treats a missing pipeline.completed as a fault will misreport a correctly-functioning gated pipeline:
pipeline.startedis not emitted on a--resume-fromrun. The event fires only whenresume_from is None, so a resumed pipeline runs its remaining stages and can emitpipeline.completedwith nopipeline.startedever having preceded it in that process. Do not pair the two as an open/close bracket.pipeline.completedis not emitted when the run endsgated_pending_approval. The terminal event fires only forfinal_statusincompleted/stopped_at_stage. A pipeline halted by a human-approval gate is a coherent terminal state, but it is not one of those two: it emits thepipeline.stage_gatedaudit event and stops. The corresponding webhook signal isapproval.requiredfrom the gating stage — that ping, not apipeline.completed, is what tells a receiver the chain is waiting on a reviewer.
Both are consequences of best-effort delivery rule 3 below being a design property rather than a failure mode: the absence of an event is never evidence that the thing did not happen.
You may pin on: the eight event literals, the seven always-present keys existing on every payload, the status value set, and the color literals. event, status, and the attachment color are guaranteed byte-exact — each is a closed set of code literals chosen by the notifier, never operator- or config-derived, so they are safe to route on.
You must tolerate growth in: the event set (new names get appended), the event-specific field set, and the free-text content of title, text, reason, run_name, and model_path. Treat every event-specific field as optional and check for presence.
Append-over-rename is the convention, and the record supports it: no webhook event name has ever changed in a released version. One rename did occur in development — training.awaiting_approval became approval.required — but both commits landed inside the same phase and the first tag containing either is v0.5.5, so no published release ever carried the old name. Adding an event requires a notify_* method, a paired audit event, a row in this table, and tests; the same discipline is stated for contributors in logging-observability.md.
Enum-validating receivers: if you hard-validate event against a list, your list needs all eight names. A receiver written against the pre-v0.7.0 vocabulary knows only five and will reject the three pipeline.* names.
These seven keys exist on every payload, on every event, every time. They are present even when null or empty, so a consumer can index without guarding.
| Key | Type | Notes |
|---|---|---|
event |
string | One of the eight names above. Never masked. |
run_name |
string | The run id for training.* and approval.required; the pipeline run id for pipeline.*. Primary correlation key. Masked as free text. |
status |
string | One of started, succeeded, failed, reverted, awaiting_approval, completed, stopped_at_stage. Never masked. |
metrics |
object, string → number | {} on every event except training.success. Non-numeric values are stripped before send. |
reason |
string or null | Non-null only for training.failure, training.reverted, and pipeline.stage_reverted. Masked, then truncated. |
model_path |
string or null | Non-null only for approval.required. A filesystem directory path. Masked as free text. |
attachments |
array of exactly one object | Slack-compatible block with exactly title (string), text (string), color (string, #rrggbb). Presentation, not data. |
Two footnotes that matter in practice:
metricsfiltering keeps booleans. The filter admits values passing a numeric type check, andTrue/Falsesatisfy it — a boolean metric arrives as JSONtrue/false, not as a number. Everything else non-numeric (strings, nulls, nested objects) is dropped silently.attachmentscarries no information not derivable from the other fields. Non-Slack receivers may ignore it entirely.
Present only on the events named. This set is closed and enforced at runtime against _ALLOWED_EXTRA_PAYLOAD_KEYS in forgelm/webhook.py.
| Key | Type | Present on | Meaning |
|---|---|---|---|
stage_count |
int | pipeline.started |
Number of stages in the chain. |
final_status |
string | pipeline.completed |
Terminal pipeline state; equals the top-level status. Observed values: completed, stopped_at_stage. |
stopped_at |
string or null | pipeline.completed |
Name of the halting stage; null when the pipeline finished cleanly. The null is meaningful and is deliberately preserved rather than filtered out. |
stage_name |
string | pipeline.stage_reverted |
Name of the stage that reverted. |
_send(**extra) is not a free-form passthrough. Every extra key is screened before it reaches the wire:
- Key screen. A key outside
_ALLOWED_EXTRA_PAYLOAD_KEYSis dropped and logged atWARNINGnaming the key, the event, the current allowlist, and the constant to register it in. It is never transmitted. - Value screen. An allowlisted value must be a JSON scalar (
str/int/float/bool) ornull. A list, dict, or arbitrary object is dropped with its ownWARNINGrather than being allowed to raiseTypeErrorinside serialization and abort an otherwise-successful run at its final step. - Collision screen. A key colliding with an always-present field is dropped, so the base envelope can never be overwritten by an extra.
A rogue field degrades the notification; it never cancels it and never raises. The rest of the payload still ships.
Is this a behaviour change for existing receivers? No. The allowlist is exactly the set of keys the shipped notify_* methods already pass, so no field that used to arrive stops arriving and no payload changes shape. The change is preventive: it closes **extra as a route for a future caller to funnel user- or config-derived text to a third-party receiver by accident. Contributors adding a field to a notify_* method must register it in the constant; tests/test_webhook.py drives every notifier and fails the build when the two drift.
Every payload below was captured from the shipped notifier, not hand-written: each notify_* method was driven with a recording transport substituted at the POST boundary, and the object it assembled is reproduced verbatim — key order, escaping, always-present nulls and all. Regenerate them the same way rather than editing them by hand, or they drift back into being a description of what the payload ought to be.
training.success — the only event that populates metrics:
{
"event": "training.success",
"run_name": "llama3-support-sft",
"status": "succeeded",
"metrics": {
"eval_loss": 0.4231,
"train_runtime": 1820.5
},
"reason": null,
"model_path": null,
"attachments": [
{
"title": "Training Succeeded: llama3-support-sft",
"text": "The job completed successfully.\n\nMetrics:\n• eval_loss: 0.4231\n• train_runtime: 1820.5000",
"color": "#36a64f"
}
]
}Note the attachment text: every metric is listed, each formatted to four decimal places, so train_runtime renders as 1820.5000 in the prose while metrics.train_runtime stays the JSON number 1820.5. The formatted text is presentation; read metrics for values.
training.reverted — same envelope, reason populated, metrics empty:
{
"event": "training.reverted",
"run_name": "llama3-support-sft",
"status": "reverted",
"metrics": {},
"reason": "Safety gate failed: unsafe_rate 0.08 exceeds max_unsafe_rate 0.05",
"model_path": null,
"attachments": [
{
"title": "Training Reverted: llama3-support-sft",
"text": "Auto-revert fired. Generated artifacts were deleted because a post-training gate (evaluation, safety, judge, or benchmark) rejected the run.\n\nReason: Safety gate failed: unsafe_rate 0.08 exceeds max_unsafe_rate 0.05",
"color": "#ff9900"
}
]
}The only event populating model_path. The value is the staging directory; no weights, tokenizer files, or compliance-bundle contents ride along.
{
"event": "approval.required",
"run_name": "llama3-support-sft",
"status": "awaiting_approval",
"metrics": {},
"reason": null,
"model_path": "./checkpoints/final_model.staging.llama3-support-sft",
"attachments": [
{
"title": "Awaiting Human Approval: llama3-support-sft",
"text": "Training completed; the model is staged at `./checkpoints/final_model.staging.llama3-support-sft` and awaiting reviewer sign-off.\nRun `forgelm approve <run_id>` to promote, or `forgelm reject <run_id>` to discard.",
"color": "#f2c744"
}
]
}pipeline.completed on a clean finish — note stopped_at: null is present, not omitted:
{
"event": "pipeline.completed",
"run_name": "align-chain-2026-07",
"status": "completed",
"metrics": {},
"reason": null,
"model_path": null,
"attachments": [
{
"title": "Pipeline Succeeded: align-chain-2026-07",
"text": "All stages completed successfully.",
"color": "#36a64f"
}
],
"final_status": "completed",
"stopped_at": null
}On the early-stop path the same event arrives with status and final_status both set to stopped_at_stage, stopped_at naming the halting stage, and the attachment color at #cc0000.
pipeline.stage_reverted — the near-real-time revert signal, carrying both stage_name and reason:
{
"event": "pipeline.stage_reverted",
"run_name": "align-chain-2026-07",
"status": "reverted",
"metrics": {},
"reason": "Benchmark gate failed: hellaswag 0.41 below min_score 0.50",
"model_path": null,
"attachments": [
{
"title": "Pipeline Stage Reverted: align-chain-2026-07",
"text": "Stage 'dpo-preference' triggered auto-revert; downstream stages will not run.\n\nReason: Benchmark gate failed: hellaswag 0.41 below min_score 0.50",
"color": "#ff9900"
}
],
"stage_name": "dpo-preference"
}pipeline.started carries stage_count and nothing else beyond the base envelope:
{
"event": "pipeline.started",
"run_name": "align-chain-2026-07",
"status": "started",
"metrics": {},
"reason": null,
"model_path": null,
"attachments": [
{
"title": "Pipeline Started: align-chain-2026-07",
"text": "Multi-stage training pipeline began with 3 stage(s).",
"color": "#0052cc"
}
],
"stage_count": 3
}Redaction is payload-wide, applied once to the fully assembled object immediately before serialization. Every free-text string passes through forgelm.data_audit.mask_secrets: run_name, reason, model_path, every allowlisted string extra, and the attachment title and text. Redacted spans become the literal [REDACTED-SECRET], covering AWS / GitHub / Slack / OpenAI / Google keys, JWTs, private-key blocks, and Azure storage connection strings.
| Guarantee | Detail |
|---|---|
| Exempt from masking | event, status, and the attachment color — guaranteed byte-exact. Each is a closed set of code literals chosen by the notifier itself, never operator- or config-derived, so receivers may safely route on them. |
| Masking scope | The assembled payload, not individual arguments. |
| Masker unavailable | If forgelm.data_audit cannot be imported, every free-text field is replaced wholesale with the literal "[REDACTED — secrets masker unavailable]" rather than shipped raw. event and status still survive, so the ping stays correlatable. Receivers should tolerate this string in any free-text field. |
| Length cap | Only reason is capped, at 2048 characters, with a "… (truncated)" suffix when cut. Other fields are masked but not truncated. |
| Webhook URLs | Never present in any payload. In operator logs they are masked to scheme://host — path, query, and userinfo stripped, because Slack / Teams / Discord carry the bearer token there. They are likewise excluded from model cards and from the persisted compliance manifest, which keeps url_env and never url. |
| Model artefacts | No payload on any event contains model weights or tokenizer bytes. The field names state_dict, model.safetensors, pytorch_model.bin, and adapter_model never appear. |
Masking the assembled payload rather than each argument closes a real leak class rather than an instance: stage_name used to be masked in its own field while the raw value was interpolated into the attachment text two lines later, and run_name had the same shape in title and text. Stage names and run names come from operator YAML, so both were config-derived text going out on a wire.
Design your receiver against these properties, all of which follow from webhook delivery being a best-effort side channel that must never fail a training run:
- No retries. A failed delivery is not re-attempted. Any claim that ForgeLM retries with exponential backoff is stale.
- No ordering guarantee. Do not infer sequence from arrival order.
- No delivery receipt. The absence of an event is not evidence that the thing did not happen.
- Be idempotent. Nothing prevents a duplicate in a re-run of the same
run_name. - Failures are swallowed. Policy rejection, timeout, connection error, any
requests.RequestException, and a missingrequests-toolbeltare each logged atWARNINGand absorbed. - Non-2xx bodies are discarded. Only the status code is logged; the response body is deliberately suppressed, because receivers routinely echo the payload back.
- Webhook traffic is not the audit record. For long-term history, snapshot the run's
audit_log.jsonl— the append-only hash-chained record — rather than archiving pings. Seeverify_audit.mdfor its location and verification.
audit_event_catalog.md— the webhook ↔ audit-log correlation table and the full Article 12 event catalogue.configuration.md— thewebhook:config block:url,url_env,timeout,notify_on_*,require_https,allow_private_destinations,tls_ca_bundle.verify_annex_iv_subcommand.md— the pipeline manifest verifier, whosepipeline.*audit events share names with three of the webhook events documented here.logging-observability.md— contributor-facing rules for adding a webhook event.../guides/cicd_pipeline.md— wiring webhook notifications into a CI/CD gate.forgelm/webhook.py— the implementation this document describes.