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।