> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Ruby SDK: Orbit quickstart for Ruby

> Official Orbit Ruby SDK quickstart — messaging, voice, contacts, campaigns, and OTP verify with copy-pasteable examples.

# Ruby SDK

The Orbit Ruby SDK wraps the platform's core API resources — messaging (SMS, WhatsApp, email), voice, contacts, campaigns, verify (OTP), and webhook signature verification — with typed methods. It requires Ruby 2.7+.

<Note>
  **Pre-publish — source-only.** This SDK is not yet on RubyGems — the
  Gemfile line below describes the future registry shape. Until first
  publish, vendor the source from the monorepo (`packages/sdk-ruby/`) or
  call the [REST API](/api-reference) directly. See
  [Ruby: core-scope, not full parity](/sdks#ruby-core-scope-not-full-parity)
  on the SDK index for exactly what is and isn't wrapped, and the low-level
  `client.request(method, path, **opts)` escape hatch for uncovered routes
  (worked example [below](#covered-route-missing-use-the-escape-hatch)).
</Note>

## Installation

Add to your Gemfile:

```ruby theme={null}
gem "orbit_sdk", "~> 0.2"
```

Or `gem install orbit_sdk`. Requires Ruby 2.7+.

## Client initialization

```ruby theme={null}
require "orbit_sdk"

client = OrbitSdk::Client.from_api_key("dv_live_sk_...")
```

Or read the key from an environment variable (`ORBIT_API_KEY`):

```ruby theme={null}
client = OrbitSdk::Client.from_env
```

Tune timeouts and retries with the direct constructor:

```ruby theme={null}
client = OrbitSdk::Client.new(
  api_key: "dv_live_sk_...",
  base_url: "https://api.orbit.devotel.io/api/v1",
  timeout_s: 30,
  max_retries: 3,
  initial_backoff_s: 1.0,
  user_agent: "my-app/1.0",
)
```

## Quickstart: send your first SMS

A runnable end-to-end — the key comes from `ORBIT_API_KEY`, never from
source. Copy it into `first_send.rb` and run it with `ruby first_send.rb`:

```ruby theme={null}
require "orbit_sdk"

client = OrbitSdk::Client.from_env  # reads ORBIT_API_KEY

result = client.messages.send_sms(to: "+14155552671", body: "Hello from Orbit!")

puts "message id: #{result.dig('data', 'id')}"      # msg_abc123
puts "status: #{result.dig('data', 'status')}"      # 'queued' — watch it reach 'delivered'
puts "status URL: /api/v1/messages/#{result.dig('data', 'id')}"
```

Point `ORBIT_API_KEY` at a sandbox key prefixed `dv_test_sk_` first —
sandbox sends are simulated, free, and never reach a carrier. Swap in your
live key (`dv_live_sk_...`) when you're ready to send for real; the code
does not change.

The response shape above comes from the
[Messages API reference](/api-reference/endpoints/messaging) — its language
tabs include this exact call.

## Messaging

```ruby theme={null}
result = client.messages.send_sms(to: "+14155552671", body: "Hello from Orbit!")
puts result.dig("data", "id")

client.messages.send_whatsapp(to: "+14155552671", body: "Hello!")
client.messages.send_email(to: "a@example.com", subject: "Hi", body: "<p>Hello!</p>")
client.messages.get("msg_123")
```

## Voice

```ruby theme={null}
client.voice.create(to: "+14155552671")            # outbound calls
client.voice.get("call_123")
client.voice.list
client.voice.hangup("call_123")
```

Outbound voice always exits through Devotel's wholesale softswitch — the SDK submits a request to the Orbit API and never selects a carrier itself.

## Verify (OTP)

The send-and-check round trip is the most common first integration on the
platform — here as a complete flow. Send the code, keep the returned
verification id, and check the code the user typed in against it:

```ruby theme={null}
# 1. Send an OTP (channel: sms | whatsapp | email).
sent = client.verify.send(to: "+14155552671", channel: "sms")

# 2. Check the code the user typed in, against the id send returned.
result = client.verify.check(verification_id: sent.dig("data", "id"), code: "482901")

puts "valid: #{result.dig('data', 'valid')}"      # true
puts "status: #{result.dig('data', 'status')}"    # 'approved'

# Inspect the full lifecycle of one verification request:
detail = client.verify.get_detail(sent.dig("data", "id"))
```

A wrongly-typed or expired code reports `valid: false` — it never raises
for a bad guess (only for transport/auth failures), so branch on the flag.
The [Verify API reference](/api-reference/endpoints/verify) covers the full
contract, and [Starter examples](/guides/starter-examples) ships a complete
OTP sign-in starter repo (`orbit-otp-nextjs`).

## Contacts

```ruby theme={null}
client.contacts.create(phone: "+14155552671", first_name: "Ada")
client.contacts.list(search: "ada")
client.contacts.get("contact_123")
client.contacts.update("contact_123", company: "Acme")
client.contacts.add_tags("contact_123", ["vip"])
client.contacts.delete("contact_123")
```

## Campaigns

```ruby theme={null}
client.campaigns.create(name: "Spring sale", channel: "sms")
client.campaigns.list
client.campaigns.get("cmp_123")
client.campaigns.send("cmp_123")
client.campaigns.delete("cmp_123")
```

## Paginate a list

List endpoints are cursor-paginated — read `meta.pagination.cursor` and
`meta.pagination.has_more` off each response and pass the cursor back as a
query param until `has_more` is false. Page through SMS messages with the
escape hatch:

```ruby theme={null}
params = { channel: "sms", limit: 100 }
total = 0

loop do
  page = client.request("GET", "/messages", query: params)

  total += page["data"].length
  pagination = page.dig("meta", "pagination")
  break unless pagination["has_more"]

  params[:cursor] = pagination["cursor"]
end

puts "Fetched #{total} messages"
```

The full pagination model (cursor vs. offset endpoints, page-size caps, and
why cursors are not bookmarkable) is in the
[Pagination guide](/guides/pagination).

## Error handling

All Orbit-originated errors inherit from `OrbitSdk::OrbitError`:

| Exception                            | Raised when                                                |
| ------------------------------------ | ---------------------------------------------------------- |
| `OrbitSdk::OrbitAuthenticationError` | 401/403 — bad or missing-scope API key                     |
| `OrbitSdk::OrbitRateLimitError`      | 429 after retries exhausted; sleep `e.retry_after`         |
| `OrbitSdk::OrbitClientError`         | other 4xx (invalid request)                                |
| `OrbitSdk::OrbitServerError`         | 5xx after retries exhausted, or persistent network failure |
| `OrbitSdk::OrbitError`               | base class — rescue to handle any Orbit-originated error   |

```ruby theme={null}
begin
  client.messages.send_sms(to: "+14155552671", body: "...")
rescue OrbitSdk::OrbitRateLimitError => e
  sleep(e.retry_after || 5)
rescue OrbitSdk::OrbitAuthenticationError
  # rotate API key
rescue OrbitSdk::OrbitError => e
  warn "orbit: #{e.code} (#{e.status}) — #{e.message}"
end
```

Every non-GET request automatically carries an `Idempotency-Key` header (UUIDv4); override it per call with your own stable key (`idempotency_key: "job-7a3b9d-attempt-1"`).

## Covered route missing? Use the escape hatch

The typed clients wrap 8 core resources; the rest of the API — contact segments, event sinks, frequency caps, and everything else listed as out of scope on the [SDK index](/sdks#ruby-core-scope-not-full-parity) — is reachable through `client.request(method, path, **opts)`. It returns the raw JSON body as a `Hash`. Fetch a segment by id:

```ruby theme={null}
segment = client.request("GET", "/contacts/segments/seg_01hxyz")
puts segment.dig("data", "name")
```

The escape hatch carries the same auth, retry, and error model as the typed clients — treat it as a first-class client, not a fallback `net/http` call.

## Webhook signature verification

```ruby theme={null}
require "orbit_sdk"

begin
  event = OrbitSdk::Webhooks.verify(
    payload: request.body.read,
    signature: request.headers["X-Orbit-Signature"],
    secret: ENV.fetch("ORBIT_WEBHOOK_SECRET"),
  )
rescue OrbitSdk::OrbitWebhookSignatureError
  # Forgery — drop, do NOT 200.
  return 400
end

# event is a Hash — match on event["type"], etc.
```
