Back to Blog
Guide

Slack Webhook curl Example, Plus Python and Node.js

Send a Slack message with one curl command: POST a JSON body with a text field to your incoming webhook URL, and Slack answers ok. Tested examples for curl, jq, Block Kit, Python with retries and Node.js, how to escape quotes and new lines, and what 400 invalid_payload and 429 mean in code.

Slack Green Team
September 28, 2026
October 6, 2026
6 min read
Share:
slack
webhooks
api

To send a Slack message with curl, POST a JSON body with a text field to your incoming webhook URL:

curl -X POST "$SLACK_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d '{"text": "Deploy finished: *web* is live"}'

Slack replies with the two letters ok and posts the message in the webhook's channel. If the JSON is broken, you get HTTP 400 with invalid_payload. Keep the URL in an environment variable such as SLACK_WEBHOOK_URL: anyone who has it can post to your channel. If you do not have a URL yet, how to create a Slack webhook covers the four steps.

We ran the code examples on 28 September against a local stand-in server that checks the JSON and answers the way Slack's docs describe, and logged the exact bodies each one sent. The answers for a wrong or deleted webhook URL below come from Slack itself, sent to hooks.slack.com on October 4, 2026.

How a Slack webhook call works

  • • One URL, one channel. The webhook posts to the channel picked when the app was installed. Slack's docs say you cannot override the channel, username or icon per message; they come from the app. Tools that take a webhook URL, such as Grafana's Slack contact point, are bound to that one channel as well.
  • • The body is JSON. Send Content-Type: application/json and at least text, or blocks plus text as the notification fallback. For bot messages sent with chat.postMessage, desktop notifications now read the blocks first, as our fallback text test shows.
  • • The answer is plain text. Success is ok, not a JSON object, and Slack does not return the message ts, so you cannot thread replies to it without the Events API. A webhook can still post into an existing thread if you pass that thread's thread_ts; we tested it in Slack thread_ts, tested.
  • • About one message per second. Slack's rate limit page lists incoming webhooks at 1 per second, with short bursts allowed. Over that you get HTTP 429 with a Retry-After header.
  • curl examples: text, variables and Block Kit

    Send a message that contains quotes or new lines. Do not paste shell variables into a JSON string by hand. Let jq build the JSON, so quotes and line breaks are escaped:

    MESSAGE='Build "42" failed on main
    see the log'
    jq -n --arg text "$MESSAGE" '{text: $text}' |
      curl -sS -X POST "$SLACK_WEBHOOK_URL" -H "Content-Type: application/json" --data-binary @-

    The body that arrived was {"text": "Build \"42\" failed on main\nsee the log"}: the quotes became \" and the line break \n, which Slack shows as a new line.

    Send a formatted message with Block Kit. Put the JSON in a heredoc and send it with --data-binary, which keeps the body exactly as written:

    curl -X POST "$SLACK_WEBHOOK_URL" \
      -H "Content-Type: application/json" \
      --data-binary @- <<'EOF'
    {
      "text": "Deploy finished for web",
      "blocks": [
        {"type": "section", "text": {"type": "mrkdwn", "text": "*Deploy finished* for `web`\n<https://example.com/runs/42|Open the run>"}}
      ]
    }
    EOF

    Links use <url|text> in webhook messages. The [text](url) form works only in the Slack message composer's markup mode, not in mrkdwn sent by apps and webhooks. To turn normal Markdown into Slack's format first, paste it into our free Markdown to Slack converter, which also previews the result. All the syntax is in Slack Block Kit markdown.

    You do not have to write Block Kit JSON by hand: build it in Slack's builder and copy the payload, as in how to use Slack Block Kit Builder.

    Slack webhook in Python

    This uses only the standard library. It retries when Slack rate-limits you and raises an error with Slack's message for anything else:

    import json
    import os
    import time
    import urllib.error
    import urllib.request
    
    URL = os.environ["SLACK_WEBHOOK_URL"]
    
    
    def send(text, retries=3):
        body = json.dumps({"text": text}).encode()
        req = urllib.request.Request(URL, data=body, headers={"Content-Type": "application/json"})
        for attempt in range(retries):
            try:
                with urllib.request.urlopen(req, timeout=10) as resp:
                    return resp.read().decode()  # "ok"
            except urllib.error.HTTPError as e:
                if e.code == 429:  # rate limited: wait as long as Slack asks
                    time.sleep(int(e.headers.get("Retry-After", "1")))
                    continue
                raise RuntimeError(f"{e.code} {e.read().decode()}")  # e.g. 400 invalid_payload
        raise RuntimeError("still rate limited")
    
    
    print(send('Nightly job done: 1,204 rows, "0" errors'))

    It printed ok. json.dumps escapes the quotes in the text, so the same function works for any message. To post with a bot token instead, reply in threads, or keep the message ts, use slack_sdk, shown with real output in send a Slack message from Python. The same webhook call from PowerShell, with the ConvertTo-Json -Depth setting that Block Kit needs, is in send a Slack message from PowerShell.

    Slack webhook in Node.js

    Node 18 and newer have fetch built in, so no package is needed. Save it as send.mjs and run node send.mjs:

    const url = process.env.SLACK_WEBHOOK_URL;
    
    const res = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "Backup finished in 42 s" }),
    });
    const body = await res.text(); // "ok", or an error such as "invalid_payload"
    if (!res.ok) throw new Error(`${res.status} ${body}`);
    console.log(body);

    It printed ok on Node 26. JSON.stringify does the escaping.

Never appear "away" on Slack again

Cloud-based. No downloads. Works 24/7 even when your laptop is off.

What Slack answers when the webhook URL is wrong

A Slack webhook URL has three parts after /services/: the workspace ID (starts with T), the webhook ID (starts with B) and a secret. We sent the same small JSON body to hooks.slack.com with parts of the URL wrong, and Slack answered differently for each:

POST /services/T00000000/B00000000/XXXX...   (unknown workspace)   -> 404  no_team
POST /services/T0B7JBCDKC1/B00000000/XXXX... (real workspace,
                                             unknown webhook)       -> 404  no_service
GET  /services/T00000000/B00000000/XXXX...   (GET instead of POST)  -> 404  no_team
POST /services/T0B7JBCDKC1                   (URL cut short)        -> 302  redirect, nothing posted
POST /triggers/T0B7JBCDKC1/000.../xxxx...    (Workflow Builder
                                             trigger, unknown)      -> 404  {"ok":false,"error":"webhook_not_found"}

So no_team means the T part is wrong, which usually means the URL was copied incompletely or from another workspace. no_service means the workspace is right but that webhook does not exist, because it was deleted, its app was removed, or the B or secret part is wrong. A 302 means the URL is cut short; curl without -L prints nothing, which looks like success but posts nothing. Workflow Builder webhook URLs live under /triggers/ and answer in JSON, not plain text.

Two more limits from our own tests with a real webhook: when we went over the rate limit, Slack answered 429 with the body rate_limited and Retry-After: 1 (29 September), and a webhook posted at most 4 messages of 4,000 characters for a 16,000-plus character text and dropped the rest (1 October).

Slack webhook error 400: invalid_payload and bad request

A 400 from a webhook almost always means the JSON is not valid. The classic mistake is an unescaped quote:

curl -i -X POST "$SLACK_WEBHOOK_URL" -H "Content-Type: application/json" \
  -d '{"text": "He said "hi""}'

The inner quotes end the string early, so the body {"text": "He said "hi""} is not JSON, and the answer is HTTP 400 with invalid_payload. Slack's docs say such a request "should not be retried without correction". The fixes:

  • • Build the JSON with jq, json.dumps or JSON.stringify instead of string pasting.
  • • Write a new line as \n inside the string, never a raw line break.
  • • Send files with --data-binary @file; plain -d @file drops the file's line breaks.
  • • Include text. Without text or blocks, Slack answers no_text.
  • Webhook responses to handle in code

    ResponseWhat your code should do
    200 okDone. The body is plain text
    400 invalid_payloadFix the JSON; do not retry as is
    400 no_textAdd a text field
    403 action_prohibitedAn admin blocked posting; ask them
    404 no_teamThe workspace part of the URL is wrong; copy the URL again
    404 no_serviceThe webhook was removed or disabled; create a new URL
    302 redirectThe URL is cut short; nothing was posted
    410 channel_is_archivedUnarchive the channel or make a new webhook
    429 Too Many RequestsWait Retry-After seconds, then send again

    The full list, with what each one means for setup, is in how to create a Slack webhook. When we archived a channel on 5 October, its webhook answered 404 no_active_hooks instead of the 410 above, even after the channel was unarchived; every error we reproduced is in Slack webhook not working. For CI, how to send a Slack message from GitHub Actions has a ready workflow, and how to add a new line in Slack covers line breaks in API messages.

    FAQ

    What does a Slack webhook URL look like?

    https://hooks.slack.com/services/T.../B.../...: the workspace ID, the webhook ID and a secret. Workflow Builder webhooks start with https://hooks.slack.com/triggers/ instead.

    What does a Slack webhook return on success?

    HTTP 200 with the plain-text body ok. It does not return the message ts.

    Can a webhook post to a different channel?

    No. Each webhook posts to the channel chosen when it was created. Make one webhook per channel, or use a bot token with chat.postMessage.

    How do I mention someone in a webhook message?

    Use their user ID: <@U0123456789>. A plain @name stays text. Where to find the ID, @here and user groups are in how to mention a user in a Slack webhook.

    Always Active

    Stop Jiggling Your Mouse.

    Join hundreds of remote workers who never worry about their Slack status. Set it up once, stay green forever.

    Related Articles

    Guide

    Slack Bot Scopes: The Scope Each API Method Needs, Measured on 44 Methods

    We gave a Slack bot one scope, called 44 common Web API methods, and read the needed field of every missing_scope error. Then we added the scopes and called them again. The table shows which scope clears each method.

    Slack Green Team
    Guide

    Slack File Size Limit: 1 GB Is Exactly 1,073,741,824 Bytes, Tested

    We uploaded files of 50 MB, exactly 1 GiB and 1 GiB plus one byte to Slack through its upload API. The limit is 1,073,741,824 bytes, and a failed upload can still leave a broken file behind.

    Slack Green Team
    Guide

    Slack Dark Mode: Where the Setting Is, and Which Custom Emoji Disappear

    Slack's dark mode is under Preferences > Appearance > Color Mode, with Light, Dark and System. We switched modes in Slack web with three test emoji and measured which ones vanish.

    Slack Green Team