CanHook
Features Pricing Docs FAQ Blog Log in Get started free

How to Handle Slack's URL Verification Handshake and 3-Second Rule

By The CanHook Team · August 26, 2026 · 1004 words
In short

To pass Slack's URL verification handshake, read the challenge value from the incoming JSON body and echo it back in a 200 response within 3 seconds. After that, every real event needs the same fast acknowledgment - Slack retries a slow endpoint and eventually disables it, so queue slow work and respond first.

Slack's Events API won't deliver a single real event to your endpoint until it passes a one-time URL verification handshake, and every event after that comes with its own hard deadline: respond within 3 seconds or Slack calls it a failure. Get the challenge response wrong and Slack rejects your Request URL outright. Get the 3-second rule wrong once you're live and Slack retries, then quietly disables the subscription. Both are easy to fix once you know exactly what Slack expects back.

What Slack sends during verification

When you save a Request URL under Event Subscriptions, Slack immediately sends one POST request to it, as documented in Slack's Events API reference, with a JSON body shaped like this:

{
  "token": "legacy-verification-token",
  "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
  "type": "url_verification"
}

Your endpoint's only job is to read the challenge value and send it straight back, unmodified, with an HTTP 200 status. Slack does not check your response headers or care about the rest of your app's behavior at this stage - it is purely a can-you-read-a-JSON-body-and-echo-one-field check.

Responding to the challenge correctly

In Node with Express:

app.post('/slack/events', express.json(), (req, res) => {
  if (req.body.type === 'url_verification') {
    return res.status(200).json({ challenge: req.body.challenge });
  }

  // Acknowledge every real event immediately, then process it.
  res.status(200).send();
  processSlackEvent(req.body);
});

In Python with Flask:

@app.route('/slack/events', methods=['POST'])
def slack_events():
    data = request.get_json()

    if data.get('type') == 'url_verification':
        return jsonify(challenge=data['challenge']), 200

    queue.enqueue(process_slack_event, data)
    return '', 200

Either a raw text body containing just the challenge string, or a JSON body like {"challenge": "..."}, passes. What fails it: wrapping the value in extra quotes, adding a trailing newline your framework didn't ask for, returning a non-200 status, or a handler that touches a slow dependency (a cold-starting database connection, an outbound call to another API) before it replies.

The 3-second rule doesn't end at verification

The same 3-second deadline applies to every event Slack sends after verification passes, not just the handshake. If your handler does the real work - posting to another service, writing to a database, calling an LLM - before it responds, you are racing the clock on every single message. Acknowledge first with a bare 200, then hand the payload off to a queue or a background job. The examples above already split it that way: the response goes out on the first line, the actual processing happens after.

Miss the deadline and Slack does not just drop the event. It retries the same event a few times with increasing backoff, so a slow handler can end up processing the same message more than once if you don't already have an idempotency check for duplicate deliveries. Keep missing it and Slack disables the event subscription until someone goes back into the app settings and re-verifies the URL.

The same clock applies to Slack's other request types on the same kind of route: slash commands and interactive component payloads (buttons, modals, select menus) also expect an initial 200 within 3 seconds, then let you follow up asynchronously through a response_url for anything that takes longer. If one route in your app handles events, commands, and interactivity together, the acknowledge-first pattern has to cover all three, not just events.

Testing the handshake before your code is done

You don't need a finished handler to see exactly what Slack will send. Point Slack's Request URL at a CanHook catch URL temporarily and save the setting - the catch URL accepts the POST at any method, stores the full headers and body, and returns whatever mock response you've configured for it. Open the captured request in the dashboard and you'll see the real challenge value, the exact header set, and the content-type Slack used, instead of guessing from documentation.

A CanHook catch URL can't complete the real handshake for you - it returns a fixed configured response, not a dynamic echo of whatever challenge value came in, so Slack will still reject it as your permanent Request URL. What it's useful for is the next step: once you've captured a real challenge or event payload, use Replay to resend that exact request body against your own local dev server as many times as you need, without waiting for Slack to redeliver it or re-triggering verification from the app settings every time you change a line of code.

Common mistakes that fail verification

  • Body-parsing middleware isn't wired up for that route. If req.body comes back undefined or as a raw buffer, you'll echo undefined instead of the challenge string.
  • Returning the challenge with extra structure. {"result": {"challenge": "..."}} is not the same as {"challenge": "..."} - Slack expects the field at the top level, or the bare string as plain text.
  • A global auth middleware rejecting the request. If every route under /slack/* requires a session or API key, the verification POST never reaches your handler and Slack sees a 401 or 403.
  • CSRF protection eating the POST. Framework-level CSRF middleware that checks for a token on every POST route will reject Slack's request before your handler runs. Exempt the webhook route explicitly.
  • Doing real work before responding. Even a fast database write can push you past 3 seconds under load. Acknowledge, then process.

After verification passes

Once your Request URL is verified, Slack starts sending real events to the same endpoint - the url_verification payload never appears again unless you change the URL or Slack invalidates the subscription. From here, the job shifts from handling one special payload correctly to verifying every incoming request's signature (Slack signs events differently than GitHub does, but the principle - verify before you trust the body - is identical) and keeping your handler fast enough that the 3-second clock never becomes something you have to think about.

Neither check takes much code. The handshake is a few lines that read one field. The 3-second rule is a discipline: respond first, work second. Get both right once and you won't touch this part of the integration again.

Frequently asked questions

What does Slack send during the URL verification handshake?

A single POST request with Content-Type application/json and a body containing type: url_verification, a challenge string, and, for older app configurations, a legacy verification token. Your endpoint must read the challenge value and return it in the response body with a 200 status.

Do I need to return the challenge as JSON or plain text?

Either works. Slack accepts a raw text body containing just the challenge string, or a JSON body like {"challenge": "..."}. What it rejects is anything else in the body, a non-200 status, or a response that arrives after the 3-second window.

What happens if my endpoint doesn't respond within 3 seconds?

Slack treats the request as failed. For the initial verification, your Request URL configuration is rejected and Slack shows an error in the app settings. For live events, Slack retries a few times with backoff, and an endpoint that keeps timing out gets its event subscription disabled.

Can I use CanHook to complete Slack's real verification handshake?

No. Slack needs your own endpoint to echo back the exact challenge value it sends, and a CanHook catch URL returns a fixed configured response. Use the catch URL to capture and inspect the real payload shape while you build your handler, then point Slack at your own code.

Should I verify Slack's request signature before or after handling the challenge?

Slack does not sign the initial url_verification request the same way it signs later events, so most teams handle that one request first and add full request-signature verification for every event that follows. Check the signature before you trust any event body.

Why did Slack ask me to re-verify a URL that already worked?

Slack re-runs verification whenever you change the Request URL in your app's Event Subscriptions settings, or after Slack's own infrastructure invalidates a stale subscription. It is not a sign that your original handler broke.