Outbound Webhooks (planned)
⚠️ Not yet implemented. This page documents the planned shape so external integrators can prep against a stable contract. Tracked in backlog.md under "Deferred Ideas — Backend / Infrastructure" (2026-04-27 entry: "Outbound webhooks: notify the originating external system…"). No code ships behind this URL today; the payload below is design-locked but emission is gated.
When implemented, outbound webhooks will close the loop opened by /api/v1/sync. Today external systems POST to Shrubbery to drive transitions. Outbound is the reverse: Shrubbery POSTs to a registered endpoint when one of its own state changes affects a Shrubbery that carries an (externalSystem, externalId) mapping.
Use case
A user accepts a Handshake in the Accord and later marks the Shrubbery Completed from the Detail Sheet. The originating Jira ticket should reflect that the commitment was delivered. Today, Shrubbery has no path to push that signal back; the integrator must poll or re-call /api/v1/sync after manual reconciliation. Outbound webhooks remove the gap.
Planned event types
| Event | Trigger |
|---|---|
shrubbery.completed | A Shrubbery transitions to Completed. The first event scoped for v1. |
shrubbery.refused | A Shrubbery transitions to Refused (Knight declined during Pending_Handshake). |
shrubbery.renegotiated | A Shrubbery is Renegotiated — substantive edit by the Lead reverted it to Pending_Handshake. |
shrubbery.activated | A Shrubbery transitions to Active (Knight Handshaked). Lower priority — external systems can derive this from the Accord acceptance UI. |
Planned payload shape
{
"event": "shrubbery.completed",
"id": "9c9ab2d7-0e7e-4cf8-86df-7d6b9bfab2d1",
"externalSystem": "jira",
"externalId": "PROJ-123",
"previousStatus": "Active",
"newStatus": "Completed",
"completedAt": "2026-09-30T15:42:17.000Z",
"actor": {
"kind": "user",
"userId": "0d0c0a78-9e7f-4cf8-86df-7d6b9bfab2d1"
},
"timestamp": "2026-09-30T15:42:17.000Z"
}The same envelope shape applies to every event; only event, newStatus, and the timestamp field (completedAt / refusedAt / renegotiatedAt / activatedAt) vary.
Field semantics
| Field | Notes |
|---|---|
event | Stable event name. Programmatic consumers branch on this. |
id | Shrubbery UUID — matches the id returned by POST /api/v1/inbox. |
externalSystem / externalId | Echo of the inbound mapping. Use this to locate the corresponding entity in your system. |
previousStatus / newStatus | The transition itself, in case your consumer needs to verify ordering. |
actor | Who drove the transition. kind ∈ {user, api_token, system}. userId and (when api_token) tokenId populated. |
timestamp | Wall-clock at the moment the transition committed in the database (UTC, ISO-8601 with milliseconds). |
Planned authentication
HMAC-SHA256 over the raw request body using a shared secret registered per endpoint. Header: X-Shrubbery-Signature: sha256=<hex-hmac>. Consumers compute the HMAC and constant-time-compare. Replay protection via a X-Shrubbery-Delivery: <uuid> header + optional timestamp tolerance.
Endpoint registration will happen in Settings → Outbound Webhooks (page does not yet exist), one URL + secret pair per externalSystem value, scoped per user.
Planned retry semantics
Backed by Inngest. On non-2xx, exponential back-off up to 24 hours. After the back-off envelope expires, the delivery is marked permanently failed and surfaced on the registration page; manual replay TBD.
What to prep on your side
Even before outbound ships:
- Reserve an endpoint URL. Treat it as a contract — once Shrubbery starts POSTing, payloads will arrive without warning. Pre-stand-up the receiver in your system.
- Plan secret storage. The HMAC secret is the trust root; treat it like the Bearer token (secret manager, not env file in code).
- Decide on idempotency. Shrubbery will retry on 5xx; your receiver must tolerate duplicate
X-Shrubbery-DeliveryIDs. A simple(id, event, newStatus)log table is enough. - Decide on event subscription. When the registration UI ships, you will pick which events to receive. Start with
shrubbery.completedonly; the other three are operational signals you may not need.
What this page does not commit to
- The exact path Shrubbery POSTs to (the integrator's URL, registered per endpoint — there is no canonical Shrubbery-side path).
- The exact secret-rotation UX. Default plan: support overlapping secrets during rotation; details TBD when the registration page lands.
- The schema for delivery failure replays. Likely a per-delivery row in a
webhook_deliveriestable with a manual "retry" button; not in scope for the first ship.
The fields above (event, id, externalSystem, externalId, previousStatus, newStatus, actor, timestamp) are design-locked. Build against them safely.
Last updated: 17 May 2026