Webhook troubleshooting
Fix a failed test event or delivery by symptom, with the exact messages GrowWriter shows, what each one means, and what to do.
Last updated on
Every entry below starts with what you see, says what it means in plain words, and ends with the fix. Start with the first entry: almost every first-connection problem is solved by finding the real status code.
First, find the real status code
What to do. Send an unsigned test request to your endpoint from your own
computer and look only at the status line and the Location header:
curl -sS -o /dev/null -D - -X POST \
-H "Content-Type: application/json" -d '{}' \
https://your-site.com/webhooks/growwriter | grep -iE "^(HTTP|Location)"How to read the result. The request is not signed, so a 401 is the
good answer: your endpoint is live and rejecting unsigned requests, exactly
as it should.
| You get | It means |
|---|---|
HTTP … 401 or 400 | Good. The endpoint runs and checks signatures. Signed requests will work. |
HTTP … 3xx + Location: | Your URL redirects. Use the address in Location: instead. See redirects. |
HTTP … 404 | Wrong path, or the route isn't deployed. |
HTTP … 405 | The route exists but doesn't accept POST. |
HTTP … 500 | Your code crashed. Usually a missing environment variable (the secret). |
| Connection error / timeout | DNS, firewall, or the site isn't public. |
If you get a 3xx, run the command again with the Location: address until
you get a 4xx. That final address is the one to enter in GrowWriter.
The test event fails with HTTP 301, 302, 303, 307 or 308
What you see. The dialog shows Your endpoint URL redirects. Enter the address it redirects to and try again, with the redirect target on the line below.
What it means. Your site answered "go over there instead". Browsers follow that silently, so your site looks fine when you open it. GrowWriter never follows a redirect: following one could drop the request body or break the signature. So a redirect always counts as a failed delivery, and all three attempts go to the exact URL you entered.
The usual causes:
| Cause | Typical redirect |
|---|---|
example.com vs www.example.com | Your host redirects one to the other. Use the one it redirects to. |
http:// instead of https:// | http://… → https://… |
| A trailing slash your framework adds or removes | /webhook → /webhook/ or the reverse |
| A locale prefix your framework adds | /api/webhook → /en/api/webhook |
| A preview URL that was reassigned | *.vercel.app → your production domain |
| A CDN or proxy rule ("Always use HTTPS", page rules) | any of the above |
Fix. Copy the address from the error (or from the Location: header in
the check above) and enter it as the endpoint URL. If
the connect dialog is still open, click Back; if you already closed it,
click Edit URL on the webhook row of the Channels page. Both keep your
signing secret. Disconnecting and reconnecting instead creates a new secret,
which you would have to update on your server.
"Your endpoint did not respond in time"
What it means. GrowWriter waited 10 seconds for an answer and gave up. Each of the three attempts gets its own 10 seconds.
Fix. Answer before doing slow work. The guide describes two safe
patterns: keep the handler safe to run twice and let a retry finish the job,
or answer 200 first and do the heavy work in a background job. See
What about the 10-second limit?.
"Your endpoint rejected the delivery"
What it means. Your endpoint answered with a status that is not 2xx.
The message quotes that status and the first 200 characters of your
response body, so return a short plain-text reason from your handler and
you will read it right in the dialog. The status tells you where to look:
401or403: your signature check said no. See the signature never matches.404: wrong path. Compare the URL in GrowWriter with your route.405: the route doesn't acceptPOST.413: your server refuses bodies this large. A full article can be several hundred kilobytes. See large payloads.500: your code crashed. Check your server logs; a missing secret environment variable is the most common cause.
"View post" is missing or opens the wrong page
What it means. The link is whatever URL your endpoint returned in the
body of its 2xx answer, as {"url": "https://…"}. Answer with anything
else and there is no link. GrowWriter has no other way to know where your
site put the post.
Fix. Return the post's public URL from your handler once the post is
saved. It must be an absolute http or https URL; relative paths and
other schemes are ignored. See
The response for the rules. Two
things to know:
- If you answer
200before creating the post (the background-job pattern), you can't know its URL yet, so the dialog shows no link. - The stored link is replaced on every successful delivery. If a link points at an old address, publish the article again and your endpoint's current answer replaces it.
"Delivery failed after several attempts"
What it means. GrowWriter could not connect to your endpoint at all in three attempts: DNS failed, the connection was refused, or the site is not reachable from the internet.
Fix. Check that the URL is live, uses https://, and is public.
Addresses on private networks and localhost are blocked for security. To
receive real events on your machine while developing, use a tunnel service
(ngrok, cloudflared) and enter the tunnel's HTTPS address.
The URL is refused when I connect
GrowWriter checks the URL before saving it. The three messages and their meaning:
- The webhook URL must use HTTPS: only
https://is accepted. - The webhook URL points to a private address and cannot be used: the
host name resolves to a local or private network address (
localhost,10.x,192.168.x, and similar). Use a public address or a tunnel. - The webhook URL is invalid: the URL can't be parsed, contains a username or password, or its host name can't be resolved.
GrowWriter does not test whether the endpoint answers at this point. That's what Send test event is for.
The signature never matches
Work through this list in order. Each item explains one whole class of failures:
- Verify over the raw body. Compute the signature over the exact bytes you received, before any JSON parsing. Parsing and re-serializing changes the bytes.
- Use the whole secret. The key is the entire string,
whsec_prefix included. - Check the environment variable. Same name in your code and your host, set for the environment that serves the endpoint, and redeployed after you set it.
- Check the five-minute window. If your server's clock is off by more than five minutes, every signature looks expired. Sync the clock.
- Compare the header format.
X-GrowWriter-Signatureist=<unix seconds>,v1=<64 hex characters>, and the signed string is"<t>.<raw body>".
The local test signs a request the same way GrowWriter does, so you can confirm your check without waiting for a real delivery.
The test succeeds but real articles fail
What it means. The ping is tiny. A real article carries its content
three times (markdown, HTML, and blocks) and is typically a few hundred
kilobytes; long articles can exceed 1 MB. Servers and frameworks often cap
JSON bodies well below that.
Fix. Raise the body-size limit on the route (for example
express.raw({ type: 'application/json', limit: '10mb' }); Express's
default is 100 KB), and make sure your save logic handles the full payload,
not just the fields the test used.
The test passes, but the first article fails with a 500 on Vercel or Netlify
What it means. Your handler writes a file next to your code. Serverless
hosts mount that directory read-only, so the write throws (EROFS) and the
endpoint answers 500. The test passes because the ping returns before any
saving happens.
Fix. Store the article in a database, a CMS API, or an object store
instead of a local file. The Next.js and Express examples in the guide log
the post for exactly this reason; replace the log line with your storage
call. If you only need a scratch file, /tmp is writable but empties
between invocations.
I lost my signing secret
What it means. The secret is shown once, in the connect dialog, and is stored encrypted. GrowWriter cannot show it again.
Fix. Disconnect the webhook on the Channels page and connect it again. That creates a new secret and retires the old one: update the environment variable on your server, redeploy, then send a test event.
The article is live on my site, but my CMS admin shows a validation error
What you see. Something like Key on object value "growwriterId" is not allowed, or a date value refused.
What it means. Your endpoint saved a field your CMS doesn't declare (usually the GrowWriter id), or a value in a format the field doesn't accept (usually a full timestamp in a date-only field).
Fix. Add a dedicated field for the id to your content model, and convert dates to the format your CMS expects. Both are explained in Save the article into your CMS.
A post silently went back to draft
What it means. The payload has no published flag. If your CMS treats a missing draft field as "draft", the post is published only until someone saves it in the admin.
Fix. Set the published state yourself, explicitly, when you handle
article.published and article.updated. See
rule 3.
My build fails on { or < inside the article
What it means. Article text contains ordinary characters like {, }
and <. If you compile the markdown through MDX, JSX, or a templating
engine, those characters are read as code.
Fix. Store the markdown as data and render it with a Markdown renderer,
or escape those characters before compiling. content.html is already
escaped and safe to insert directly. See
rule 6.
Images are huge, missing, or their URLs changed
- Huge: images arrive at their original resolution and can exceed 10 MB. Resize and compress them when you download them.
- Missing:
coverImageis omitted when the article has no cover;imagesis an empty array when there are no inline images. Check for the key before reading it. - URLs changed: an image that was replaced in the article gets a new URL on the next delivery. Unchanged images keep their URLs.
All three are covered in rule 5.
I received the same article twice
- Same delivery id: a retry. Your endpoint answered late or with an error on the first attempt. Skip work you've already done for that id.
- Different delivery id,
article.publishedagain: the article was published to a second webhook connection, or the first connection had no successful delivery yet. Upsert onarticle.idand the second copy overwrites the first.
I deleted an article in GrowWriter, but it is still on my site
What it means. Deleting an article (or disconnecting the webhook) sends
article.unpublished with the article's id, slug and language, and
nothing else. If your handler treats every non-ping event as an article to
save, it either ignored the event or tried to save an article with no
content and answered 500.
Fix. Handle article.unpublished before the save: look up the post by
article.id, then hide it or delete it, whichever your site prefers. Every
receiver example in the guide has that branch. The event follows the normal
delivery rules (three attempts, then nothing), so if your endpoint was down
at the time the post stays; GrowWriter has no later retry.
The slug contains non-Latin characters, or didn't change with the title
The slug is derived from the title once, when the brief is generated. Latin
accents are folded (qué → que), but letters from other scripts are kept,
so a Japanese title produces a Japanese slug: percent-encode it before
putting it in a URL. It is never empty. Editing the title in the editor
keeps the slug, so the post's address stays stable; only picking a new title
in the writer produces a new slug. Never use the slug as a file name or a
key; use article.id, and treat the slug as display data.
"Too many requests. Please try again later"
What it means. Your account hit a limit: 20 publish requests per hour, or 60 test events per hour. Nothing is lost. The article stays in your Library and the connection stays as it was.
Fix. Wait a few minutes, then publish or send the test event again. If you publish to several channels at once, each one counts as a request.
The webhook row says "Not verified"
What it means. The endpoint has never answered a test event or a publish. GrowWriter checks the address when you connect but does not call it until you do.
Fix. Click Send test event on the webhook row. When it succeeds, the row turns Connected. If it fails, start with the status check, then redirects or rejected. Publishing to a Not verified webhook is allowed, and the first successful delivery verifies it too.
Still stuck?
Contact us from the app and include: the delivery id (from the
X-GrowWriter-Delivery header or the error), the time of the attempt, the
status your server returned, and the output of
the status check. With those four things we can usually
answer in one reply.