Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

Hooks

Five functions that observe every call, HTTP request, retry, throttle wait and failure, with the fields each one receives. A hook can watch a call but cannot fail it.

Hooks are functions the SDK calls as it works. Use them to log, count and trace. Pass them as hooks to createOutbound.

Hook Fires Once per
onAction When a call succeeds SDK call
onError When a call fails SDK call
onRequest After an HTTP request settles, whatever its outcome HTTP attempt
onRetry Before the wait that precedes a retry retry
onThrottle Before a wait for the rate budget wait
import { createOutbound, OutboundError } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";

import { fakeVendor } from "../retries/fake-vendor.ts";
import { instantClock } from "../retries/instant-clock.ts";

// A stand-in vendor: an empty 200, a 503, an empty 200, then 401 for good.
const vendor = fakeVendor(200, 503, 200, 401);

const outbound = createOutbound({
  adapter: instantly({ apiKey: "example-key" }),
  fetch: vendor.fetch,
  hooks: {
    onAction: (event) => {
      console.log("onAction", event);
    },
    onError: (event) => {
      console.log("onError", {
        action: event.action,
        error: event.error.message,
        provider: event.provider,
        workspaceId: event.workspaceId,
      });
    },
    onRequest: (event) => {
      console.log("onRequest", event);
    },
    onRetry: (event) => {
      console.log("onRetry", event);
    },
    onThrottle: (event) => {
      console.log("onThrottle", event);
    },
  },
  runtime: instantClock(),
  // One percent of Instantly's limit is one request a second, so the second
  // request has to wait.
  share: 0.01,
});

console.log("-- a call that succeeds");

await outbound.raw.get("/campaigns");

console.log("-- a call that is throttled, fails once and is retried");

await outbound.raw.get("/campaigns");

console.log("-- a call that fails");

try {
  await outbound.raw.get("/campaigns");
} catch (error) {
  if (!OutboundError.is(error)) {
    throw error;
  }
}
-- a call that succeeds
onRequest {
  attempt: 1,
  durationMs: 0,
  lane: 'default',
  method: 'GET',
  provider: 'instantly',
  status: 200,
  url: 'https://api.instantly.ai/api/v2/campaigns'
}
onAction {
  action: 'raw',
  provider: 'instantly',
  workspaceId: 'default',
  durationMs: 0,
  requests: 1,
  throttledMs: 0
}
-- a call that is throttled, fails once and is retried
onThrottle { lane: 'default', provider: 'instantly', waitMs: 1000 }
onRequest {
  attempt: 1,
  durationMs: 0,
  lane: 'default',
  method: 'GET',
  provider: 'instantly',
  status: 503,
  url: 'https://api.instantly.ai/api/v2/campaigns'
}
onRetry {
  attempt: 1,
  delayMs: 500,
  provider: 'instantly',
  reason: 'instantly responded 503',
  url: 'https://api.instantly.ai/api/v2/campaigns'
}
onThrottle { lane: 'default', provider: 'instantly', waitMs: 500 }
onRequest {
  attempt: 2,
  durationMs: 0,
  lane: 'default',
  method: 'GET',
  provider: 'instantly',
  status: 200,
  url: 'https://api.instantly.ai/api/v2/campaigns'
}
onAction {
  action: 'raw',
  provider: 'instantly',
  workspaceId: 'default',
  durationMs: 2000,
  requests: 2,
  throttledMs: 1500
}
-- a call that fails
onThrottle { lane: 'default', provider: 'instantly', waitMs: 1000 }
onRequest {
  attempt: 1,
  durationMs: 0,
  lane: 'default',
  method: 'GET',
  provider: 'instantly',
  status: 401,
  url: 'https://api.instantly.ai/api/v2/campaigns'
}
onError {
  action: 'raw',
  error: 'instantly responded 401',
  provider: 'instantly',
  workspaceId: 'default'
}

That sample runs a real Instantly adapter against a stand-in for the vendor that answers with a status code and an empty body, on a clock where waits take no real time. Both are on the retries page. durationMs is 0 on each request for that reason.

A call and a request are different things

One SDK call can send several HTTP requests: a retry, a read that needs two vendor endpoints, a page of campaigns plus their tags. onAction and onError describe the call. onRequest describes each request. The second call above is one action made of two requests.

A walk with listAll is one action per page, not one for the whole walk.

onAction

ActionEvent:

Field Type Holds
action string The operation: campaigns.list, leads.get, stats, raw, native.<name>.
provider string
workspaceId string
durationMs number Wall time for the call, waits included.
requests number Vendor requests the call has sent.
throttledMs number Milliseconds spent waiting for the rate budget.

The action names are the operation names: campaigns.list, campaigns.get, campaigns.settings, sequence.get, sequence.subsequences, senders.list, senders.get, senders.health, leads.list, leads.get, messages.list, replies.list, lists.list, tags.list, blocklist.list, webhooks.list, workspaces.list and stats. A handle method reports the operation it runs, so lead(id).timeline() reports messages.list and sender(id).warmup() reports senders.get.

onError

ErrorEvent:

Field Type Holds
action string As above.
provider string
workspaceId string
error Error The OutboundError the caller is about to receive.

It fires after the retries have run out, once per failed call.

A missing record is an answer for exists(id) and getMany, which do not throw for one. So they do not fire onError for it either: the lookup is reported to onAction like any finished call. Any other failure of those two reaches onError, and so does a NotFound from get.

A refusal that happens before the call starts does not reach onError: a capability refusal, and an input check on filters, limit or dates.

onRequest

RequestEvent:

Field Type Holds
method string GET, POST.
url string The request URL with the API key and any email address removed.
status number or null The HTTP status. null when no response arrived: a timeout, a network failure, an abort.
durationMs number For this attempt alone.
attempt number Which attempt this was, starting at 1.
lane string The rate lane the request was counted in. default unless the endpoint has its own limit.
provider string

It fires for every attempt, so a call that is retried twice fires it three times.

onRetry

RetryEvent:

Field Type Holds
attempt number The attempt that failed, starting at 1.
delayMs number How long the SDK will wait before the next attempt.
reason string The message of the error that caused the retry.
url string The request URL, cleaned as above.
provider string

It does not fire for the last failure, when no retry follows.

onThrottle

ThrottleEvent:

Field Type Holds
lane string The rate lane that is full.
waitMs number How long the SDK will wait.
provider string

It fires when a request has to wait for the rate budget. One request can wait more than once. The total for a call is throttledMs on onAction and on meta.

A hook cannot fail a call

A hook that throws, or returns a promise that rejects, is ignored. The call it was watching carries on.

import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";

const outbound = createOutbound({
  adapter: memory({ data: demoData() }),
  hooks: {
    // Both of these fail on every call.
    onAction: () => {
      throw new Error("the metrics client is down");
    },
    onRequest: () => Promise.reject(new Error("the log pipe is closed")),
  },
});

const page = await outbound.campaigns.list();

console.log("the call still answered:", page.items.length, "campaigns");
the call still answered: 2 campaigns

That has three consequences:

  • A hook’s own failure is silent. Nothing reports it. Catch and report inside the hook if you need to know.
  • A hook is not awaited. A hook may return a promise, and the SDK does not wait for it. A slow hook does not slow a call, and a hook that has not finished when your process exits is lost.
  • A hook cannot change anything. It gets a copy of what happened. To change a read, block one, or add to the result, write a plugin.

Hooks from plugins

A plugin can bring hooks of its own. They run alongside yours, yours first, then each plugin’s in the order of plugins. The tracing and audit plugins are built this way.

What a hook never sees

  • The API key: URLs and messages are cleaned first. See what is removed from messages.
  • Records and request bodies. The one payload a hook can reach is a failed response’s body, on onError as error.cause: see cause.

Was this page helpful?