Inbound webhooks
Outbound webhooks tell external systems when something changes in Project Feed. Inbound webhooks are the other direction: external systems sign a payload, POST it to Project Feed, and we create the corresponding task, post, or comment. Use them when something outside Project Feed should produce work inside it.
Pro plan — Inbound webhooks are available on Pro and above. Upgrade from Billing & Plans.
Inbound vs outbound
Outbound (event → URL)
Project Feed fires HTTP POSTs at your endpoint when an event happens. You verify the signature and react. Covered in the webhooks guide.
Inbound (URL ← event)
An external system fires HTTP POSTs at Project Feed. We verify the signature and create the matching task, post, or comment. Same HMAC scheme as outbound, just in the other direction.
Setup
- 1.
Mint a signing secret
Go to Settings → Inbound webhooks and click New secret. Name it after the sender (“GitHub Issues → Tasks”, “Linear sync”) so you can revoke just that pipeline if it ever misbehaves.
- 2.
Copy the secret into the sender
Project Feed shows the full secret once. Paste it into the calling system’s secret manager — a GitHub Actions secret, a Zapier connection, an env var in your service. The short prefix (
pfin_...) is how Project Feed looks up which secret to verify against. - 3.
Sign and POST
On the sender side, compute the HMAC, set three headers, and POST the JSON body. Project Feed validates the signature, the timestamp, and the idempotency key, then creates the resource.
Endpoints
Three endpoints, one per resource. Same auth on all three.
POST /api/v1/webhooks/inbound/tasks
POST /api/v1/webhooks/inbound/posts
POST /api/v1/webhooks/inbound/commentsSigning requests
Every inbound request sets three headers:
- •
X-Project-Feed-Secret-Prefix— the short prefix shown next to the secret in the UI. Tells Project Feed which secret to verify against. - •
X-Project-Feed-Timestamp— current Unix time in seconds. Drift past 5 minutes is rejected. - •
X-Project-Feed-Signature— hex-encoded HMAC-SHA256 of"<timestamp>.<raw_body>".
Worked example in Node:
import crypto from "node:crypto";
const secret = process.env.PROJECT_FEED_INBOUND_SECRET!;
const prefix = process.env.PROJECT_FEED_INBOUND_PREFIX!;
const body = JSON.stringify({
externalSource: "github",
externalId: "issue-1234",
title: "Investigate flaky build",
status: "todo",
});
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
await fetch("https://api.projectfeed.dev/api/v1/webhooks/inbound/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Project-Feed-Secret-Prefix": prefix,
"X-Project-Feed-Timestamp": timestamp,
"X-Project-Feed-Signature": signature,
},
body,
});Sign the raw body. Serialize first, then sign that exact string, then send that exact string. Re-serializing on the way out (a logger that pretty-prints, a middleware that adds a trailing newline) will change the bytes and break the signature.
Idempotency
Real senders retry. Networks drop packets, Lambdas time out, rate-limited jobs back off and try again. To keep retries safe, every inbound payload carries two fields:
- •
externalSource— short tag for the calling system. Examples:github,linear,zapier,pagerduty. - •
externalId— the sender’s primary key for the thing. A GitHub issue number, a Linear ticket id, a PagerDuty incident id. Anything stable.
The tuple (organizationId, externalSource, externalId) is unique. The first POST creates the resource. The second POST with the same tuple updates it. Retry storms collapse to a single record.
Example payloads
Create a task from a GitHub issue
POST /api/v1/webhooks/inbound/tasks
{
"externalSource": "github",
"externalId": "issue-1234",
"title": "Flaky CI on main",
"description": "Tests intermittently fail on the auth suite.",
"status": "todo",
"priority": "high",
"projectSlug": "platform"
}Mirror a Linear comment
POST /api/v1/webhooks/inbound/comments
{
"externalSource": "linear",
"externalId": "comment-abc-987",
"taskExternalId": "issue-1234",
"body": "Bumped to high — affects the release branch."
}Rotation and revocation
From the row, the ⟳ button rotates: the new secret displays once, the old one stops working immediately. The 🗑 button revokes: every future request signed with that secret returns 401. There is no soft-delete and no grace period — a revoked secret stays revoked.
Per-sender secrets, not one shared key. Mint one secret per upstream system. When the marketing team’s Zapier breaks loose, you revoke just that secret without taking down the GitHub sync.
Error handling
- •401 — bad signature, wrong prefix, or revoked secret. Check
X-Project-Feed-Secret-Prefixand re-derive the HMAC against the raw body. - •408 — timestamp older than 5 minutes (or in the far future). Resync the sender’s clock.
- •422 — payload validated against the schema and failed. The response body lists which fields are wrong.
- •200 on every retry of the same
(externalSource, externalId)tuple. Project Feed updates in place; the sender can keep retrying safely.
Outbound webhooks
The mirror image: Project Feed fires HTTP POSTs at your endpoint when workspace events happen.
Service accounts
For the other direction — when something inside Project Feed needs to call out to the API with a workspace credential.