Adding an inbound webhook
SkillDev toolsGuides your agent to add inbound webhook endpoints and consumers correctly in the PostHog codebase.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Adding an inbound webhook skill
About this capability
Use when adding a webhook endpoint for a third party that sends to PostHog, adding an inbound webhook consumer for a provider that already has an endpoint, or migrating a hand-rolled hmac verifier that the `inbound-webhooks-go-through-ingress` semgrep rule flags. Covers the two jobs separately: a co
What this skill tells your AI
The instructions your AI receives, as published by posthog/posthog-foss in .agents/skills/adding-inbound-webhooks/SKILL.md and read by ahel’s review.
Every webhook a third party sends to PostHog goes through posthog/ingress/.
Read posthog/ingress/README.md for the transport contract and the package reference.
This skill is the decision tree and the checklists.
Which job are you doing?
- The provider already has an endpoint (see the Endpoints table in
posthog/ingress/README.md) and you want to react to its events: add a consumer. - No endpoint exists for this third party, or the semgrep rule flagged a hand-rolled verifier: add a provider, then add its consumer.
- Outbound call to a vendor API, which is the other direction:
/routing-outbound-api-calls.
Add a consumer
A product declares its consumers in products/<product>/backend/webhook_consumers.py, in a WEBHOOK_CONSUMERS sequence of WebhookConsumer values from posthog.ingress.contracts.
products/stamphog/backend/webhook_consumers.py is the smallest complete example.
WEBHOOK_CONSUMERS = (
WebhookConsumer(
name="stamphog_review",
provider="github",
app="stamphog",
event_types=frozenset({"pull_request"}),
handler=_run_review,
),
)
Rules that decide whether this works:
nameis unique per provider and is part of the dedup cache key. Treat it as fixed once it ships: renaming one lets a redelivery run the consumer a second time.providerandappmust match aProviderSpecsome incarnation declares, andevent_typesmust be a subset of what that app declares. Anything else raisesRegistryErrorwhen the registry is built, rather than sitting there looking registered and never running.handlertakes oneWebhookDeliveryand returns nothing. Its return value is ignored and it never decides the HTTP status.- Keep the module cheap to import. The registry imports it on the first delivery through
load_product_modules("webhook_consumers"), so defer heavy imports into the handler behind# noqa: PLC0415with a reason. - The handler runs synchronously inside the request. Enqueue a task for real work, the way stamphog and conversations do.
- A handler that reads the database wraps the read in
bounded_statement_timeout(ms, models=...)fromposthog.ingress.dispatch.database, passing only the models the read actually uses. Opening an alias is itself unbounded, so naming one the read never touches can stall the delivery on connection setup. - An import-linter contract (
webhook consumers must only import facade) holds the module to its own product'sfacade/. Reach product internals through the facade. - A consumer whose resources are split across regions declares
ownership=, pointing at a facade function that returns aDeliveryOwnership. Ingress forwards the signed request when the answer isELSEWHERE, and dispatches locally either way. The lookup runs inside the request, so bound it withbounded_statement_timeout(ms, models=...).
Tests: extend the product's existing webhook test module rather than starting a parallel one.
products/stamphog/backend/tests/test_webhook_consumers.py is the shape: drive the real view with a signed RequestFactory request and assert the enqueue, plus the event type the app does not register, the bad signature, the unparseable body, the non-POST, and the missing secret.
Reset the process-cached registry and the dedup cache between tests with reset_consumer_registry() and cache.clear().
Add a provider
Create posthog/ingress/<provider>/ with an __init__.py and a provider.py.
Copy github/ for the full shape, or vapi/ for a small one.
provider.py holds three things:
SPECS, oneProviderSpecper app, naming the event types that app is subscribed to. The registry validates consumers against these.- A
WebhookProvidersubclass withscheme()(fromposthog/ingress/verify/),deliveries(request, payload, facts)(how to read the event type, delivery id and context off the verified request), and any status codes the provider's protocol fixes. Defaults are 403 on a bad signature, 500 when unconfigured, 202 on success.verify(request)answers aVerification: the outcome, plusfacts, whatever the scheme proved on the way. A scheme that validates a signed token puts its verified claims there anddeliveriescross-checks the body against them; an HMAC scheme leaves it empty anddeliveriesignores it.parse(request)decodes the body, and defaults to JSON. Override it for a provider that posts a form, and raiseInvalidPayloadfor a body it cannot read. Verification runs first and must, because readingrequest.POSTconsumes the request stream under ASGI.throttle_classnames a DRF throttle fromposthog.rate_limit, run in front of verification. Set one when the endpoint is public and its verification is expensive, such as a JWT signing-key lookup.
- A
build_<provider>_provider(...)function returning it. Secrets and verifiers a product owns are passed into this builder, never imported: nothing underposthog/ingress/may import a product.
Then:
- Add the module path to
_INCARNATION_MODULESinposthog/ingress/providers.py, or the registry never sees its specs or core consumers. - Wire the URL with
build_webhook_view()where the App registration lives. The owner of the third-party App owns the route: a product that registered the App declaresurlpatternsin its ownproducts/<product>/backend/routes.py, for exampleopt_slash_path("webhooks/<product>/<provider>", build_webhook_view(build_<provider>_provider())). The path must start withwebhooks/<product>/orapi/<product>/, or the URL conf fails to load. Only an App several products consume stays inposthog/urls.py, which today is the customer-facing GitHub App alone. See docs/internal/url-routing.md. - Write
posthog/ingress/<provider>/README.mdwith the fixed sections, in this order: headers, signature scheme, delivery id and event type, apps and secrets, quirks, consumers.posthog/ingress/test/test_provider_readme_sections.pyfails on a provider folder without one, and on a README with different or reordered headings. - Add the provider's signature header name to the
$HEADERregex in.semgrep/rules/devex/inbound-webhooks-go-through-ingress.yaml, plus a fixture case in the.pybeside it. The header names are spelled out rather than matched generically because a generic header pattern makes semgrep time out on a large module, which drops that file from the scan without failing it. - Delete the migrated endpoint's line from
paths.excludein the same rule. That list is a ratchet of verifiers that predate ingress, and the migrating PR removes its own entry. - Preserve the endpoint's externally observable behavior. Existing tests are the contract: move or extend them, do not drop assertions.
The DRF adapter path
An endpoint that genuinely needs DRF team scoping keeps its view and subclasses posthog.auth.WebhookSignatureAuthentication, which computes and compares through posthog/ingress/verify/schemes.py.
Customer.io is the reference: team-scoped, secret from that team's integration row, no fan-out, so customerio/ contributes a scheme only and declares no spec.
Everything else goes through build_webhook_view().
Non-goals
Ingress stores no delivery log, runs no queue, retry or dead letter, lets no consumer decide the response, and promises no consumer order. Each was a real proposal already; "Non-goals" in the package README records the reason for each one, so read it before proposing any of them again.
Verify
semgrep --config .semgrep/rules/devex/ . # the ratchet entry is really gone
semgrep --test .semgrep/ # only if you changed the rule itself
lint-imports # the webhook_consumers contract
hogli test products/<product>/backend/tests/test_webhook_consumers.py
hogli test posthog/ingress/test/
ruff check --fix <touched files> && ruff format <touched files>
Signals
- GitHub stars
- 715
- Forks
- 118
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
adding-inbound-webhooks- Source
- github.com/posthog/posthog-foss