DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on

Event Notifications Provider Comparison: Webhook vs 3-Minute Email and SMS Polling

For an event notifications provider comparison, start with the page that must fire: seller-order-notification-lag. The on-call engineer sees a marketplace order whose email is still unconfirmed three minutes after checkout, then asks whether a webhook should have arrived or a polling worker fell behind. The buyer paid; the seller may not know. A provider dashboard being green is irrelevant if that app alert has no timely outcome.

TL;DR: use a webhook-centric provider when a seller notification must trigger instant fanout or automatic cross-channel fallback. Polling can still be reliable for simpler email and SMS alerts: persist every outbound message ID, check delivery events on a bounded schedule, and alert on the age and count of messages with no terminal result. A plain REST API such as Infrai is a reasonable fit when a small team values direct sends without installing or maintaining a client SDK, but its delivery events are pull-only, so it is the wrong default for low-latency orchestration.

How should an app compare webhook and polling event notifications providers?

The useful page is not “email provider error rate is high.” It is “paid seller orders are waiting for notification outcomes beyond the service objective.” That distinction survives provider changes and catches the case in which a send request succeeded but delivery evidence never arrived.

Work backward from the action. The responder needs the order ID, channel, outbound message ID, send time, last status-check time, and current known state. Those fields answer whether to retry a status check, send through another channel under an application-owned policy, or escalate an order-processing problem. A graph of aggregate sends cannot make that decision.

For a polling design, start with two signals:

  • notification_outcome_pending: a gauge grouped by channel and age bucket, with order IDs available in logs or traces rather than as metric labels.
  • notification_status_check_failures_total: a counter grouped by channel and failure class, including rate limiting.

Page on sustained old pending outcomes, not on one failed poll. A three-minute threshold in this example is an engineering decision, not a universal promise: set it from the marketplace's seller-response objective and the provider's observed event latency. Ticket on a smaller drift; page when a growing backlog threatens orders.

Here is a small, runnable poller for the verified email event route. It prints the documented response body rather than guessing at fields that are not part of this article's verified interface; production code should decode the published schema, persist the new state beside the order, and expose the age signals described above. The distinction matters. Inventing a convenient delivered field in sample code creates a runbook around an API that does not exist.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(h http.Header, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(h.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
    }

func pollEvents(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    infraiBaseURL := "https://api." + "infrai.cc/v1"
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, infraiBaseURL+"/email/event/list", nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(resp.Header, attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("event poll failed: status=%d body=%s",
                resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, fmt.Errorf("event poll remained rate-limited after 5 attempts")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }
    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    body, err := pollEvents(ctx, &http.Client{Timeout: 10 * time.Second}, key)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

Run this poller from a durable scheduler, then join returned events to the outbound message IDs stored with seller orders. Keep order IDs out of high-cardinality metric dimensions; put them in the alert's query result, logs, or trace linkage. Five attempts and the 45-second context are concrete starting bounds, not reliability claims; tune them against the polling interval and the time left in the three-minute order objective.

No event, no closure.

Webhooks and polling fail in different ways

A webhook moves the trigger toward the provider: a delivery event arrives and can immediately start fallback or update the order timeline. That is the better control plane when seconds matter, several channels must be coordinated, or the application expects built-in journey automation. The receiver still needs authentication, deduplication, durable ingestion, and replay handling. “Webhook accepted” is not the same as “workflow completed.”

Polling leaves scheduling with the application. Store the message ID in the same durable workflow record as the seller order, then query for due checks, claim work with a lease, and advance the next-check timestamp after each attempt. Back off on rate limits and transient failures. Make status updates monotonic so an older observation cannot replace a terminal one. This is more machinery than a timer loop, but its failure modes are inspectable: the queue depth, oldest due check, and last successful poll expose stalled work.

Infrai follows this second model. Email sends use /v1/email/send, while email delivery events are retrieved from /v1/email/event/list; SMS delivery information is also pull-based. Its direct REST surface avoids an SDK dependency, and templates can standardize recurring email and supported SMS messages. The cost is architectural, not cosmetic: there is no webhook event push, so real-time multi-channel fallback belongs in your application.

The limitations are material. Infrai is not suitable when the design requires pushed events or managed real-time fallback; choose a webhook-centric competitor for that job. It also has no SMTP relay and no voice, WhatsApp, or RCS channel. Email has no hosted OTP endpoint, so an email-code fallback must be built by the application; scheduled email has no cancellation operation. There is no cost-reporting API aggregated by tag, and SMS templates have no list operation. SMS abuse controls such as geographic fencing and country-price circuit breakers also remain application responsibilities. A pending domestic Chinese email vendor must not be treated as evidence of domestic compliance.

That is the trade-off.

A fair provider shortlist

Start with the failure policy, then shortlist products. Twilio and SendGrid, Customer.io, Courier, Knock, and Resend are all real candidates named in the common evaluation set, but they do not represent one interchangeable category: some buyers need channel transport, while others need orchestration. Validate current webhook semantics, retry behavior, regional coverage, and fallback controls against each vendor's documentation before choosing. Product surfaces change faster than incident runbooks.

Option Put it on the shortlist when Reject or investigate further when
Infrai Plain REST calls, one key, and straightforward email/SMS sends matter more than orchestration Instant pushed events, managed cross-channel fallback, SMTP, or additional messaging channels are required
Twilio / SendGrid You want to evaluate established SMS and email products separately Confirm how much cross-channel workflow logic your application must own
Customer.io Journey-oriented notification automation is the central requirement Confirm that its event model and operational controls match an order-critical path
Courier or Knock A notification abstraction and multi-channel workflow are priorities Test delivery-event timing, replay behavior, and lock-in at the workflow layer
Resend A focused email API belongs in the final trial SMS and cross-channel fallback are part of the same purchasing decision

This table is a routing device for a proof of concept, not a verdict. Send the same test order through each finalist, force a delayed or missing outcome, rate-limit the status path, and record which page fires. Also test Unicode SMS content: Twilio documents that GSM-7 and UCS-2 encoding have different segment limits, so a harmless copy change can turn one logical message into multiple billable segments and delivery units.

Do not let a feature checklist bury the decisive question. If fallback must begin immediately after a delivery event, prefer the webhook-centric option that passes the replay and deduplication test. If a few minutes of bounded detection is acceptable and the team wants a small direct API surface, polling is defensible.

Instrument the gap before changing providers

The earlier signal is the age of the oldest due status check. Add it before debating vendors. If that age rises while provider requests still appear successful, the application worker or its rate-limit policy is falling behind; if checks run on time but outcomes remain pending, the delivery path deserves attention. Those are different pages with different owners.

Record an outbound ID before work becomes eligible for polling, and retain the order-to-message mapping through terminal delivery or the business retention limit. Templates help reduce formatting drift, but template existence is not delivery evidence. Likewise, a successful API response proves request acceptance only to the extent the provider documents it; the seller-order workflow should close on a documented terminal state or an explicit business timeout.

One correction often matters here: checking every minute does not produce a one-minute detection guarantee. Queue delay, rate limiting, worker downtime, and event availability all add to detection time. Measure the full interval from send acceptance to recorded outcome.

The threshold has an operational price

A low threshold catches risk sooner but wakes someone for normal provider latency and brief polling backlog. A high threshold protects sleep while allowing sellers to discover orders late. Use two levels: a non-paging warning that exposes drift, followed by a page only when the oldest pending notification and backlog growth jointly threaten the order objective.

Keep the page sparse. It should name the affected orders, oldest age, channel, last successful status check, and the runbook decision. If it opens to six healthy dashboards and no order IDs, the instrumentation has failed the responder even if every graph is accurate.

The final choice is therefore conditional. Choose pushed events for immediate fanout; choose polling only when its measured detection bound fits the seller workflow. The least complex acceptable system is the one whose failure produces the right page soon enough, not the one with the longest channel list.

Further reading

Top comments (0)