Skip to content

Webhooks পাঠানো

Go-তে ষাট লাইনে end-to-end producer। শেষে আপনার হাতে একটা binary থাকবে যা একটা URL-এ JSON POST করে, non-2xx সামলায়, আর পরিষ্কারভাবে timeout করে। Signing, retries, আর outbox পরে আসছে।

এই অধ্যায়ে একটা কাজ করা webhook producer ship হয়। আসল Go কোড। আমরা শুধু পাঠানোর দিকে মন দিই — অধ্যায় ৪-এ signing, অধ্যায় ৬-এ retries, অধ্যায় ১০-এ outbox pattern। শেষে আপনার হাতে একটা ছোট program থাকবে যা যেকোনো URL-এ event deliver করে আর সততার সাথে success বা failure রিপোর্ট করে।

বাস্তব জীবনের উপমা

একটা webhook পাঠানো অনেকটা একটা registered চিঠি পোস্ট করার মতো — আপনি পাঠান, একটা receipt পান, কিন্তু প্রাপক আলাদাভাবে delivery নিশ্চিত করে।

গল্পে বুঝি

ফাতিমা আল-ফিহরি একটা কুরিয়ার অফিস চালান। ইবনে সিনার নামে একটা জরুরি নোটিশ-স্লিপ এসেছে, আর তাঁর খাতায় লেখা আছে ইবনে সিনার রেজিস্টার্ড করা ঠিকানা — কোন গলি, কোন বাড়ি। ফাতিমা একজন রানারের হাতে স্লিপটা তুলে দেন। রানার সেই ঠিকানায় হেঁটে যায়, দরজায় গিয়ে কড়া নাড়ে, আর একটা নিয়ম মেনে চলে — একটা যুক্তিসংগত সময় দাঁড়িয়ে অপেক্ষা করবে, তার বেশি নয়।

সকালে ইবনে সিনা নিজেই দরজা খুললেন, স্লিপ নিলেন, আর রিসিট-বইয়ে সই করে দিলেন — রানার খুশিমনে ফিরে গিয়ে “ডেলিভার্ড” লিখল। কিন্তু পরদিন আল-খোয়ারিজমির ঠিকানায় গিয়ে দেখা গেল অন্য চিত্র: একবার এত ডাকাডাকির পরও ভেতর থেকে কেউ সাড়া দিল না, রানার নির্দিষ্ট সময় দাঁড়িয়ে থেকে ফিরে এল; আরেকবার ঠিকানাটাই ভুল বেরোল, বাড়ির লোক স্লিপ নিতে সাফ মানা করে দিল। দুই ক্ষেত্রেই রানার খাতায় “ব্যর্থ চেষ্টা — পরে আবার” টুকে রাখল।

গল্পটা হুবহু webhook পাঠানো। রানারের স্লিপ নিয়ে রেজিস্টার্ড ঠিকানায় হেঁটে যাওয়া = provider-এর subscriber-এর URL-এ payload নিয়ে POST করা; ইবনে সিনার সই করে নেওয়া = একটা 2xx success response, মানে ডেলিভার্ড; নির্দিষ্ট সময়ে কেউ সাড়া না দেওয়া = timeout; আর ভুল বা মানা-করা ঠিকানা = non-2xx failure — timeout আর non-2xx দুটোই retry-র জন্য চিহ্নিত। বাস্তবেও তাই: Stripe বা GitHub আপনার event পাঠানোর সময় ঠিক এভাবেই একটা timeout সেট করে POST করে, 2xx পেলে সফল ধরে, না পেলে পরে backoff দিয়ে আবার চেষ্টা করে।

যা যা লাগবে

  • Go 1.22+ (go version)।
  • একটা receiver — আপাতত, webhook.site আপনাকে একটা free public URL দেয় যা browser-এ আসা POST দেখায়। আমরা টেস্টিংয়ের জন্য সেটা ব্যবহার করি।
mkdir webhook-sender && cd webhook-sender
go mod init example.com/webhook-sender

Basic sender-এর জন্য কোনো dependency নেই। আমরা ID-র জন্য oklog/ulid/v2 যোগ করব।

go get github.com/oklog/ulid/v2

Minimum viable sender

// sender.go
package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/oklog/ulid/v2"
)

type Event struct {
    ID         string          `json:"id"`
    Type       string          `json:"type"`
    Created    time.Time       `json:"created"`
    APIVersion string          `json:"api_version"`
    Data       json.RawMessage `json:"data"`
}

func newEvent(typ string, payload any) (*Event, error) {
    data, err := json.Marshal(map[string]any{"object": payload})
    if err != nil {
        return nil, err
    }
    return &Event{
        ID:         "evt_" + ulid.Make().String(),
        Type:       typ,
        Created:    time.Now().UTC(),
        APIVersion: "2026-05-01",
        Data:       data,
    }, nil
}

var httpClient = &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    },
}

func send(ctx context.Context, url string, ev *Event) error {
    body, err := json.Marshal(ev)
    if err != nil {
        return fmt.Errorf("marshal: %w", err)
    }

    req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
    if err != nil {
        return err
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("User-Agent", "myapp-webhooks/1.0")
    req.Header.Set("X-Webhook-ID", ev.ID)
    req.Header.Set("X-Webhook-Type", ev.Type)
    req.Header.Set("X-Webhook-Timestamp", fmt.Sprintf("%d", ev.Created.Unix()))

    resp, err := httpClient.Do(req)
    if err != nil {
        return fmt.Errorf("post: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
        return fmt.Errorf("non-2xx: %d %s body=%q", resp.StatusCode, resp.Status, snippet)
    }
    io.Copy(io.Discard, resp.Body) // drain so connection is reusable
    return nil
}

func main() {
    url := os.Getenv("WEBHOOK_URL")
    if url == "" {
        log.Fatal("set WEBHOOK_URL")
    }

    ev, err := newEvent("payment.succeeded", map[string]any{
        "id":       "py_" + ulid.Make().String(),
        "amount":   4200,
        "currency": "usd",
        "customer": "cus_42",
    })
    if err != nil {
        log.Fatal(err)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
    defer cancel()

    if err := send(ctx, url, ev); err != nil {
        log.Fatalf("send: %v", err)
    }
    log.Printf("delivered %s", ev.ID)
}
WEBHOOK_URL=https://webhook.site/your-id-here go run .
# 2026/05/04 12:00:01 delivered evt_01HF5J7XK4TG6N2VRT9P0M3DZ4

webhook.site refresh করুন — আপনি POST, header, JSON body দেখতে পাবেন। End-to-end।

একটা কাজ করা sender-এর জন্য এটা মোটামুটি ৬০ লাইন। গড়নটা সর্বজনীন: একটা event বানাও, marshal করো, POST করো, status check করো, error surface করো।

যা মন দিয়ে পড়বেন

explicit Timeout সহ http.Client Go-র default HTTP client-এর কোনো timeout নেই। যে receiver connection accept করে অথচ কখনও response দেয় না, সে আপনার sender-কে চিরকাল ঝুলিয়ে রাখবে। সবসময় একটা set করুন। ১০ সেকেন্ড উদার; ৫ আরও aggressive।

idle conn pooling সহ Transport একই host-এ TCP connection reuse করলে প্রতি call-এ handshake এড়ানো যায়। যে sender একই receiver-এ হাজার হাজার event deliver করে, তার জন্য এটা অর্থবহ। একটা event-এর জন্য অপ্রাসঙ্গিক।

io.Copy(io.Discard, resp.Body) একটা সূক্ষ্ম Go gotcha: response body drain না করলে pool থেকে connection reuse করা যায় না। library একটা in-progress response দেখে আর পরের call-এ একটা নতুন connection খোলে। সবসময় drain করুন, success-এও।

সীমিত error body capture। io.LimitReader(resp.Body, 512) error response থেকে সর্বোচ্চ 512 byte পড়ে। limit ছাড়া, একটা buggy server megabyte-খানেক HTML ফেরত দিলে আপনার sender OOM হতে পারে। Cap করুন।

timeout সহ Context। client timeout একটা safety net; request context আরেকটা। যেকোনো একটা trigger হলে call abort হয়। Belt-and-braces।

Timeout বেছে নেওয়া

তিন tier ভেবে দেখার মতো:

  • Connect timeout — TCP+TLS complete হতে কতক্ষণ অপেক্ষা। ~৩ সেকেন্ডই যথেষ্ট।
  • Request timeout — read সহ মোট সময়। ~১০ সেকেন্ড বেশিরভাগ receiver কভার করে।
  • Per-event budget — একটা worker queue-এর জন্য, একটা event-এ কতক্ষণ কাটিয়ে হাল ছেড়ে পরে retry করবেন? ~৩০ সেকেন্ড।

Go-র http.Client.Timeout হলো মোট request timeout। আলাদা connect/read timeout-এর জন্য, সেগুলো Transport-এ configure করুন:

&http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,
        KeepAlive: 30 * time.Second,
    }).DialContext,
    ResponseHeaderTimeout: 5 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}

বেশিরভাগ production sender-এর জন্য, client-এ সরল Timeout: 10 * time.Second-ই যথেষ্ট।

Transient vs permanent failure আলাদা করা

সব failure retry করা উচিত না। receiver 410 Gone return করল? endpoint মৃত; retry করে কিছু হয় না। 500 return করল? সম্ভবত transient — পরে আবার চেষ্টা করুন।

type DeliveryResult struct {
    StatusCode int
    Err        error
    Permanent  bool
}

func classify(statusCode int, err error) DeliveryResult {
    res := DeliveryResult{StatusCode: statusCode, Err: err}
    if err != nil {
        // network errors are transient by default
        return res
    }
    switch statusCode {
    case 410, 401, 403, 404:
        // gone, unauthorized, forbidden, not found
        res.Permanent = true
    case 400, 422:
        // bad request — payload is wrong, no retry will fix it
        res.Permanent = true
    }
    return res
}

একটা আসল classifier আরও সূক্ষ্ম হবে (অধ্যায় ৬ retry semantics বিস্তারিত করে)। মূল নীতি: 4xx সাধারণত permanent (payload নিজেই সমস্যা); 5xx আর network error transient।

error-কে permanent হিসেবে mark করায় রক্ষণশীল হোন। একটা misconfigured receiver থেকে আসা 404 আর একটা মৃত endpoint থেকে আসা 404 একই দেখতে। আপনি খুব আগ্রহভরে event delete করলে customer-রা তাদের integration ভাঙা অবস্থায় পায়, কোনো উপায় ছাড়াই। সন্দেহ হলে, retry করুন।

যা log করবেন

প্রতিটা delivery attempt-এ, structured log:

webhook-deliver event_id=evt_... type=payment.succeeded url=https://... status=200 dur=243ms
webhook-deliver event_id=evt_... type=payment.succeeded url=https://... status=502 dur=11s err="non-2xx"

field-গুলো:

  • event_id — একটা event-এর সব attempt grep করার জন্য।
  • type — per-type metric-এর জন্য।
  • url — per-receiver metric-এর জন্য। query string secret ধারণ করলে সেগুলো strip করুন।
  • status + dur — latency আর error tracking-এর জন্য।
  • err — সংক্ষিপ্ত error বর্ণনা; পূর্ণ stack trace শুধু debug level-এ।

এটাই observability pipeline-এর ভিত্তি হয়ে দাঁড়ায় (অধ্যায় ৯)।

smee.io বা ngrok দিয়ে লোকালে টেস্ট করুন

sender-কে production receiver-এর দিকে তাক করার আগে, লোকালে টেস্ট করুন:

ngrok — একটা tunnel দিয়ে একটা local port-কে internet-এ expose করে।

ngrok http 3000
# https://abc123.ngrok-free.app -> http://localhost:3000

port 3000-এ একটা Go receiver চালান যা আসা request print করে। WEBHOOK_URL=https://abc123.ngrok-free.app/webhooks set করুন আর সেগুলো বয়ে আসতে দেখুন।

smee.io — free public webhook proxy। smee.io-তে যান, একটা URL নিন, আপনার sender-কে সেটার দিকে তাক করুন, forward করার জন্য smee --url <url> --target http://localhost:3000 চালান।

দুটোই dev-only tool। Production-এ receiver একটা public URL-এ চলে।

অনেক event পাঠানো — work pool

একটা queue আর N goroutine যোগ করলে single-event sender একটা multi-event worker হয়ে যায়:

func worker(ctx context.Context, jobs <-chan Job) {
    for job := range jobs {
        if err := send(ctx, job.URL, job.Event); err != nil {
            log.Printf("[%s] send failed: %v", job.Event.ID, err)
            // chapter 6 will add: requeue with backoff
            continue
        }
    }
}

func main() {
    jobs := make(chan Job, 1000)
    for i := 0; i < 16; i++ {
        go worker(ctx, jobs)
    }
    // producers push to jobs
}

16 worker একটা fast receiver-এ ~1500 deliveries/sec টানতে পারে, ধীর receiver-এ কম। মেপে tune করুন; একটা receiver-এ অতিরিক্ত parallelise করবেন না (rate limit-এ ঠোক্কর খাবেন)।

পূর্ণ ছবিটা — durable queue, retries, dead-letter — পরের অধ্যায়গুলোতে আসে। আপাতত মূল কথা: scale out করা মানে goroutine প্লাস একটা channel।

অনেক receiver-এ পাঠানো

পাঁচজন customer একই event-এ subscribe করলে, আপনি fan out করেন:

for _, sub := range subscriptions {
    select {
    case jobs <- Job{URL: sub.URL, Event: ev}:
    default:
        log.Printf("queue full")
        // back-pressure or drop
    }
}

প্রতিটা subscription একটা delivery attempt হয়ে দাঁড়ায়। পাঁচ subscriber = পাঁচটা POST। অধ্যায় ৮ per-subscription failure handling কভার করে — একজন customer-এর ভাঙা endpoint বাকিদের delivery ধীর করবে না।

এখনও যা করিনি

এই sender:

  • payload sign করে না। URL জানা যে কেউ event জাল করতে পারে। অধ্যায় ৪।
  • retry করে না। একটা transient failure আর event গায়েব। অধ্যায় ৬।
  • outbound event persist করে না। পাঠানোর মাঝপথে crash হলে হারায়। অধ্যায় ১০।
  • এর কোনো dashboard নেই। কী fail করল operator দেখতে পারে না। অধ্যায় ৯।

এটাই একটা আসল সিস্টেমের বেশিরভাগ অংশ। কিন্তু আগে basic POST কীভাবে কাজ করে জানলে পরের প্রতিটা layer-এর একটা পরিষ্কার “এটা কোন সমস্যা সমাধান করে” থাকে।

রিক্যাপ

  • Sender হলো JSON body সহ একটা POST, অধ্যায় ৪-এ signed, অধ্যায় ৬-এ retried।
  • একটা আসল Timeout সহ http.Client — default বিপজ্জনক।
  • response body drain করুন (io.Copy(io.Discard, ...)) যাতে TCP connection reuse হয়।
  • Failure classify করুন: 4xx বেশিরভাগ permanent, 5xx আর network বেশিরভাগ transient।
  • “permanent” নিয়ে রক্ষণশীল হোন — দুবার বেশি retry করা event drop করার চেয়ে সস্তা।
  • প্রতিটা attempt event ID, type, URL, status, duration, error সহ log করুন।
  • ngrok বা smee.io দিয়ে লোকালে টেস্ট করুন।
  • একটা channel-এর ওপর worker pool দিয়ে scale করুন; per subscription fan out করুন।
  • Sign, retry, persist, dashboard — পরে আসছে।

পরবর্তী: Payload signing — HMAC, canonical string, আর replay-এর বিরুদ্ধে রক্ষা করা timestamp।