| 1 | # Receiving webhooks |
| 2 | |
| 3 | Ghost POSTs JSON to each webhook URL when one of the events it subscribes to happens. This page covers what a delivery looks like and how a receiver checks that it came from Ghost. |
| 4 | |
| 5 | ## Headers |
| 6 | |
| 7 | | Header | Value | |
| 8 | |---|---| |
| 9 | | `Content-Type` | `application/json |
| 10 | | `User-Agent` | `Ghost-Hookshot/1 |
| 11 | | `X-Ghost-Event` | The event, such as `issue.opened |
| 12 | | `X-Ghost-Delivery` | A UUID for this delivery. A retry keeps it; a manual redelivery gets a new one. | |
| 13 | | `X-Ghost-Timestamp` | When this attempt was signed, in Unix seconds | |
| 14 | | `X-Ghost-Signature-256` | `sha256= |
| 15 | | `X-Hub-Signature-256` | `sha256= |
| 16 | |
| 17 | Both signatures use the endpoint's signing secret, the `whsec_…` value shown once when the webhook is created. |
| 18 | |
| 19 | ## Verifying a delivery |
| 20 | |
| 21 | Check `X-Ghost-Signature-256`: |
| 22 | |
| 23 | 1. Read the raw request body as bytes. Do not parse the JSON and serialize it again: the signature covers the exact bytes Ghost sent, and a round trip through a JSON parser changes them. |
| 24 | 2. Read `X-Ghost-Timestamp` and reject the delivery if it is more than five minutes from your clock. The timestamp is inside the signed data, so a delivery captured by someone else cannot be replayed later with a fresh one. |
| 25 | 3. Compute HMAC-SHA256 with the signing secret as the key over the timestamp, a `.`, and the raw body. Hex-encode it and put `sha256=` |
| 26 | 4. Compare the result with the header in constant time. An ordinary `==` returns sooner the earlier the strings differ, which lets an attacker guess a signature one character at a time. |
| 27 | 5. Deduplicate on `X-Ghost-Delivery`. Delivery is at least once: a delivery your server handled but did not answer in time is sent again. |
| 28 | |
| 29 | Reply with any 2xx status within 10 seconds, and do slow work after replying. |
| 30 | |
| 31 | ### Node.js |
| 32 | |
| 33 | ```ts |
| 34 | import { createHmac, timingSafeEqual |
| 35 | |
| 36 | const TOLERANCE_SECONDS = 5 |
| 37 | |
| 38 | export function verifyGhostWebhook( |
| 39 | secret: string, |
| 40 | rawBody: Buffer |
| 41 | timestamp: string |
| 42 | signature: string |
| 43 | ): boolean { |
| 44 | if (!timestamp || |
| 45 | if (Math.abs |
| 46 | |
| 47 | const expected = |
| 48 | const a = |
| 49 | const b = |
| 50 | return a.length |
| 51 | } |
| 52 | ``` |
| 53 | |
| 54 | With Express, keep the raw body by parsing it with `express.raw`: |
| 55 | |
| 56 | ```ts |
| 57 | app.post('/webhook' |
| 58 | const ok = |
| 59 | process.env.GHOST_WEBHOOK_SECRET |
| 60 | req.body, |
| 61 | req.get('X-Ghost-Timestamp |
| 62 | req.get('X-Ghost-Signature-256 |
| 63 | ); |
| 64 | if (!ok) |
| 65 | res.sendStatus(204) |
| 66 | handle(req.get |
| 67 | }); |
| 68 | ``` |
| 69 | |
| 70 | `timingSafeEqual` throws when the two buffers differ in length, so compare the lengths first. |
| 71 | |
| 72 | ### Python |
| 73 | |
| 74 | ```python |
| 75 | import hashlib |
| 76 | import hmac |
| 77 | import time |
| 78 | |
| 79 | TOLERANCE_SECONDS = 5 * |
| 80 | |
| 81 | |
| 82 | def verify_ghost_webhook(secret: |
| 83 | if not timestamp or not signature: |
| 84 | return False |
| 85 | try: |
| 86 | if abs(time.time |
| 87 | return False |
| 88 | except ValueError: |
| 89 | return False |
| 90 | digest = hmac.new(secret.encode() |
| 91 | return hmac.compare_digest(f"sha256= |
| 92 | ``` |
| 93 | |
| 94 | In Flask the raw body is `request.get_data()`; in Django it is `request.body`. |
| 95 | |
| 96 | ### Go |
| 97 | |
| 98 | ```go |
| 99 | func VerifyGhostWebhook(secret, |
| 100 | ts, err := |
| 101 | if err != nil |
| 102 | return false |
| 103 | } |
| 104 | mac := hmac. |
| 105 | mac.Write([]byte |
| 106 | mac.Write(rawBody |
| 107 | expected := "sha256=" |
| 108 | return hmac.Equal([] |
| 109 | } |
| 110 | ``` |
| 111 | |
| 112 | ### Receivers written for GitHub |
| 113 | |
| 114 | A receiver or library that already checks GitHub's `X-Hub-Signature-256` works unchanged with the same secret. That signature covers the body only, so it gives no replay protection: prefer `X-Ghost-Signature-256` when you write the check yourself. |
| 115 | |
| 116 | ## Retries |
| 117 | |
| 118 | - A 2xx response is a success. |
| 119 | - A 410 Gone turns the webhook off. |
| 120 | - A 429 is retried after its `Retry-After`. |
| 121 | - Anything else, including a timeout, a redirect or a connection error, is retried with exponential backoff and jitter, at most an hour apart, for three days. After that the delivery is marked failed and can be redelivered from the webhook's page. |
| 122 | - A webhook whose deliveries all failed for three days is turned off, and the repository's or organization's admins get an email. |
| 123 | |
| 124 | Ghost does not follow redirects, and it refuses URLs that resolve to private or reserved addresses. |
| 125 | |
| 126 | ## Chat services |
| 127 | |
| 128 | A URL that is a Slack, Discord, Google Chat or Microsoft Teams incoming webhook gets a message in that service's own format instead of Ghost's JSON. It is sent as the Ghost app, with its name and logo where the service allows that. These services do not check signatures; the headers above are sent anyway. |