Skills DirectorySkills Directory
SkillsLearnSecurityCategoriesDocsCommunityBlog
Sign InSubmit Skill
Skills Directory

Security-tested agent skills for Claude, coding agents, and AI workflows.

Directory

  • Browse Skills
  • All Skills A–Z
  • Claude Skills
  • Claude Code Skills
  • Agent Skills
  • Categories
  • Authors
  • Submit a Skill

Learn

  • Learn Hub
  • Install Claude Skills
  • Write SKILL.md
  • Skills vs MCP
  • Directories Compared

Security

  • Security
  • Methodology
  • Secure Claude Skills
  • Security Badges

Company

  • About
  • Community
  • Blog
  • API Docs
  • Advertise

2026 Skills Directory. All rights reserved.

ProTermsPrivacyRefunds
Back to skills

Adding Inbound Webhooks

ASecurity

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 consumer in `products/<product>/backend/webhook_consumers.py`, and a provider incarnation under `posthog/ingress/<provider>/` wired with `build_webhook_view()`. Carries the rules that are easy to get...

39,909 stars
0 votes
0 copies
2 views
Added 9/20/2026
developmentpythongoreactawsgitapidatabasebackend

Works with

api

Security Analysis

A100/100

Scanned 9/24/2026

Install to Claude Code

$npx -y skills add PostHog/posthog --skill adding-inbound-webhooks --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Adding Inbound Webhooks?

Add the live security badge to your README — it updates automatically with every re-scan.

Security grade badge for Adding Inbound Webhooks
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/posthog-adding-inbound-webhooks-posthog/badge)](https://www.skillsdirectory.com/skills/posthog-adding-inbound-webhooks-posthog)

More formats (shields.io, HTML) on the badges page.

Download with Pro
Files
SKILL.md
---
name: adding-inbound-webhooks
description: >
  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 consumer in `products/<product>/backend/webhook_consumers.py`, and a provider incarnation under `posthog/ingress/<provider>/` wired with `build_webhook_view()`.
  Carries the rules that are easy to get wrong: a consumer name is a dedup cache key, event types must be declared by the provider app or the registry raises, and the module stays cheap to import.
  Trigger terms: add a webhook, webhook consumer, inbound webhook, hand-rolled hmac, inbound-webhooks-go-through-ingress.
---

# Adding an inbound webhook

Every webhook a third party sends to PostHog goes through `posthog/ingress/`.
Read [posthog/ingress/README.md](../../../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`.

Before you migrate a verifier, read what the existing code verifies with.
A verifier the vendor ships in its SDK, or one from a maintained library, stays as it is: it is written by the people who define the scheme, and a reimplementation only adds bugs.
Ingress wraps such a verifier at most. Only a hand-rolled `hmac` check is a migration.

## 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.

```python
WEBHOOK_CONSUMERS = (
    WebhookConsumer(
        name="stamphog_review",
        provider="github",
        app="stamphog",
        event_types=frozenset({"pull_request"}),
        handler=_run_review,
    ),
)
```

Rules that decide whether this works:

- `name` is 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.
- `provider` and `app` must match a `ProviderSpec` some incarnation declares, and `event_types` must be a subset of what that app declares. Anything else raises `RegistryError` when the registry is built, rather than sitting there looking registered and never running.
- `handler` takes one `WebhookDelivery` and returns nothing. Its return value is ignored and never decides the HTTP status. A handler that raises does cost the request its receipt when the provider sets `retry_status`, which is how a consumer whose durable record is written inside the handler gets the delivery again.
- 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: PLC0415` with 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=...)` from `posthog.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's `facade/`. Reach product internals through the facade. `hogli product:lint` holds the same rule by AST for every product that has a `webhook_consumers.py`, including relative imports and products with no contract yet.
- A consumer whose resources are split across regions declares `ownership=`, pointing at a facade function that returns a `DeliveryOwnership`. Ingress forwards the signed request when the answer is `ELSEWHERE`, and dispatches locally either way. The lookup runs inside the request, so bound it with `bounded_statement_timeout(ms, models=...)`. Let a transient error out of the lookup rather than answering `LOCAL` or `UNDECIDED` through it: a lookup that raises asks a provider with `retry_status` for the delivery again, and a guessed answer receipts a delivery the other region never sees.

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.
Copy the layout, not the behavior: a provider package holds only what is specific to its third party. A need that a second provider could share becomes a lane, a scheme option or a `WebhookProvider` attribute, the way `retry_status` and `throttle_class` did. A true one-off stays in the provider with a `# One-off:` comment that says why no other provider needs it.
`provider.py` holds three things:

- `SPECS`, one `ProviderSpec` per app, naming the event types that app is subscribed to. The registry validates consumers against these.
- A `WebhookProvider` subclass with `scheme()` (from `posthog/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 a `Verification`: the outcome, plus `facts`, whatever the scheme proved on the way. A scheme that validates a signed token puts its verified claims there and `deliveries` cross-checks the body against them; an HMAC scheme leaves it empty and `deliveries` ignores it.
  - `parse(request)` decodes the body, and defaults to JSON. Override it for a provider that posts a form, and raise `InvalidPayload` for a body it cannot read. Verification runs first and must, because reading `request.POST` consumes the request stream under ASGI.
  - `throttle_class` names a DRF throttle from `posthog.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.
  - `retry_status` is the status answered instead of the receipt when ingress cannot vouch that the delivery was accepted: an ownership lookup failed, the forward to the owning region failed, a consumer raised, or the budget skipped a consumer. Set it when the provider redelivers on a non-2xx, and leave it `None` when it does not, because the non-2xx then only loses the delivery. A retry replays the delivery against every consumer on the endpoint, and dedup is what stops the ones that already accepted it from running twice.
- A `build_<provider>_provider(...)` function returning it. Secrets and verifiers a product owns are **passed into this builder**, never imported: nothing under `posthog/ingress/` may import a product.
  A builder that takes an `app` name calls `require_known_app(provider, app, SPECS)` from `posthog/ingress/providers.py` first, so a typo raises `UnknownApp` at import instead of serving an endpoint no consumer is registered against.

### Picking a scheme

Three exist. Configure one; do not write a fourth without reading [the Schemes section of the package README](../../../posthog/ingress/README.md#schemes).

- `HmacSha256` (`verify/schemes.py`) — a shared secret over the raw body. Covers hex or base64, an optional prefix, and the `v0:{timestamp}:{body}` input with a replay window that Slack and Customer.io sign. GitHub, Slack, PandaDoc, Vapi and Customer.io all use it.
- `SnsSignature` (`verify/schemes.py`) — the AWS SNS envelope check plus a topic-ARN allowlist. The RSA work stays with a caller-supplied verifier.
- `BearerJwt` (`verify/jwt.py`) — a `Bearer` token signed as a JWT, checked against the issuer's published JWKS. Its `facts` are the verified claims. The incarnation supplies the JWKS URI, the audience and the issuer allowlist as callables, and caches any discovery it does to find the URI. An endpoint on this scheme sets `throttle_class`, because an unsigned request costs a signing-key lookup.

Then:

1. Add the module path to `_INCARNATION_MODULES` in `posthog/ingress/providers.py`, or the registry never sees its specs or core consumers.
2. 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 declares `webhook_urlpatterns` in its own `products/<product>/backend/routes.py`, for example `opt_slash_path("<provider>", build_webhook_view(build_<provider>_provider()))`. Core mounts that list at `webhooks/<product>/`, so the route is relative to it. Only an App several products consume stays in `posthog/urls.py`, which today is the customer-facing GitHub App alone. See [docs/internal/url-routing.md](../../../docs/internal/url-routing.md).
3. Write `posthog/ingress/<provider>/README.md` with 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.py` fails on a provider folder without one, and on a README with different or reordered headings.
4. Add the provider's signature header name to the `$HEADER` regex in `.semgrep/rules/devex/inbound-webhooks-go-through-ingress.yaml`, plus a fixture case in the `.py` beside 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.
5. Do not add the endpoint to `paths.exclude` in the same rule. Every verifier that predated ingress is migrated, so the rule has no grandfathered paths left.
6. 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 of its own, lets no consumer decide the response, and promises no consumer order.
It does answer `retry_status` when it cannot vouch that a delivery was accepted, which asks the provider's retry to run rather than adding one here.
Each was a real proposal already; ["Non-goals" in the package README](../../../posthog/ingress/README.md#non-goals) records the reason for each one, so read it before proposing any of them again.

## Verify

```sh
semgrep --config .semgrep/rules/devex/ .          # no hand-rolled verifier is left
semgrep --test .semgrep/                          # only if you changed the rule itself
lint-imports                                      # the webhook_consumers contract
hogli product:lint <product>                      # the AST backstop for that 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>
```

Attribution

PostHogPostHog
View sourceMore from PostHog →
SSkills DirectorySkills Directory

Your tool, in front of Claude Code builders.

3 founder slots · $299/mo · GSC-verified traffic · sponsors can never buy grades.

See placements

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments (0)

No comments yet. Be the first to comment!

SSkills DirectorySkills Directory

Your tool, in front of Claude Code builders.

3 founder slots · $299/mo · GSC-verified traffic · sponsors can never buy grades.

See placements

Related Skills

Browser Extension Developer

Use this skill when developing or maintaining browser extension code in the `browser/` directory, including Chrome/Firefox/Edge compatibility, content scripts, background scripts, or i18n updates.

284972 votes

Seo Optimizer

SEO optimization with keyword analysis, readability assessment, technical validation, content quality. Use for search rankings, blog posts, content audits, or encountering keyword density, readability scores, meta tags, schema markup errors.

2192 votes

Google Official Seo Guide

Official Google SEO guide covering search optimization, best practices, Search Console, crawling, indexing, and improving website search visibility based on official Google documentation

1862 votes

Tanstack Start

Build a full-stack TanStack Start app on Cloudflare Workers from scratch — SSR, file-based routing, server functions, D1+Drizzle, better-auth, Tailwind v4+shadcn/ui. Use whenever the user mentions TanStack Start, asks to scaffold a full-stack Cloudflare app with SSR, wants an SSR dashboard, or asks for a React 19 + Cloudflare Workers app with file-based routing and server functions — even if they don't name TanStack Start specifically. No template repo — Claude generates every file fresh per ...

10311 votes

Pentest

PTES-aligned adversarial security audit for backend, frontend, and mobile applications. Produces a CVSS-scored Hacker Report with verified PoCs and phased remediation.

5491 votes
View all in development →