> ## 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.

# .NET SDK: Orbit quickstart for C#

> Official Orbit .NET SDK quickstart — messaging, voice, contacts, campaigns, and OTP verify with typed async methods.

# .NET SDK

The Orbit .NET SDK wraps the platform's core API resources — messaging (SMS, WhatsApp, email), voice, contacts, campaigns, verify (OTP), and webhook signature verification — with typed async methods. It targets .NET 8.0 (LTS) and has zero external runtime dependencies.

<Note>
  **Pre-publish — source-only.** This SDK is not yet on NuGet —
  `dotnet add package Orbit.Sdk` fails today. Until first publish, vendor
  the source from the monorepo (`packages/sdk-csharp/`) or call the
  [REST API](/api-reference) directly. See
  [.NET: core-scope, not full parity](/sdks#net-core-scope-not-full-parity)
  on the SDK index for exactly what is and isn't wrapped, and the low-level
  `client.RequestAsync(method, path, ...)` escape hatch for uncovered routes
  (worked example [below](#covered-route-missing-use-the-escape-hatch)).
</Note>

## Installation

```bash theme={null}
dotnet add package Orbit.Sdk --version 0.1.0
```

Or via Package Manager:

```powershell theme={null}
Install-Package Orbit.Sdk -Version 0.1.0
```

## Client initialization

```csharp theme={null}
using Orbit.Sdk;

var client = OrbitClient.FromApiKey("dv_live_sk_...");
```

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

```csharp theme={null}
var client = OrbitClient.FromEnv();
```

Tune timeouts and retries with the direct constructor:

```csharp theme={null}
var client = new OrbitClient(
    apiKey: "dv_live_sk_...",
    baseUrl: "https://api.orbit.devotel.io/api/v1",
    timeout: TimeSpan.FromSeconds(30),
    maxRetries: 3,
    initialBackoff: TimeSpan.FromSeconds(1));
```

Inject a custom `HttpClient` (with handlers for OpenTelemetry, mTLS, etc.):

```csharp theme={null}
var client = new OrbitClient(
    apiKey: "dv_live_sk_...",
    transport: new HttpClientTransport(myHttpClient));
```

`OrbitClient` is safe for concurrent use across tasks — share a single instance per process (DI singleton).

## Quickstart: send your first SMS

A runnable end-to-end — the key comes from `ORBIT_API_KEY`, never from
source. Copy it into `Program.cs` and run it with `dotnet run`:

```csharp theme={null}
using Orbit.Sdk;

var client = OrbitClient.FromEnv();  // reads ORBIT_API_KEY

var result = await client.Messages.SendSmsAsync("+14155552671", "Hello from Orbit!");
var data = result.GetProperty("data");

Console.WriteLine($"message id: {data.GetProperty("id").GetString()}");      // msg_abc123
Console.WriteLine($"status: {data.GetProperty("status").GetString()}");      // "queued"
Console.WriteLine($"status URL: /api/v1/messages/{data.GetProperty("id").GetString()}");
```

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

```csharp theme={null}
var result = await client.Messages.SendSmsAsync(
    to: "+14155552671",
    body: "Hello from Orbit!");

Console.WriteLine(result.GetProperty("data").GetProperty("id").GetString());
```

`client.Messages` also exposes `SendWhatsAppAsync`, `SendEmailAsync`, and `GetAsync`.

## Voice

```csharp theme={null}
var call = await client.Voice.CreateAsync(to: "+14155552671", from: "+14155550100", record: true);
var callId = call.GetProperty("data").GetProperty("id").GetString();

var calls = await client.Voice.ListAsync(direction: "outbound", status: "completed", limit: 20);
var detail = await client.Voice.GetAsync(callId!);
await client.Voice.HangupAsync(callId!);
```

## Verify (one-time codes)

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:

```csharp theme={null}
// 1. Send an OTP (channel: sms | whatsapp | email).
var sent = await client.Verify.SendAsync(to: "+14155552671", channel: "sms");
var verificationId = sent.GetProperty("data").GetProperty("id").GetString();

// 2. Check the code the user typed in, against the id SendAsync returned.
var checkResult = await client.Verify.CheckAsync(verificationId!, code: "482901");
var data = checkResult.GetProperty("data");

Console.WriteLine($"valid: {data.GetProperty("valid").GetBoolean()}");     // True
Console.WriteLine($"status: {data.GetProperty("status").GetString()}");    // "approved"
```

A wrongly-typed or expired code reports `valid: false` — it never throws
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`).

Still pending and the user didn't get the code? Re-dispatch on the same id:

```csharp theme={null}
await client.Verify.ResendAsync(verificationId!);
```

## Contacts

```csharp theme={null}
var created = await client.Contacts.CreateAsync(
    phone: "+14155552671",
    firstName: "Jane",
    lastName: "Doe");
var contactId = created.GetProperty("data").GetProperty("id").GetString();

var page = await client.Contacts.ListAsync(search: "jane", limit: 20);
var contact = await client.Contacts.GetAsync(contactId!);
await client.Contacts.UpdateAsync(contactId!, new Dictionary<string, object?> { ["last_name"] = "Smith" });
await client.Contacts.DeleteAsync(contactId!);
```

## Campaigns

```csharp theme={null}
var campaign = await client.Campaigns.CreateAsync(name: "Spring Promo", channel: "sms", messageTemplate: "Hi {{name}}!");
var campaignId = campaign.GetProperty("data").GetProperty("id").GetString();

var campaigns = await client.Campaigns.ListAsync();
var detail = await client.Campaigns.GetAsync(campaignId!);
await client.Campaigns.UpdateAsync(campaignId!, new Dictionary<string, object?> { ["name"] = "Spring Promo v2" });
await client.Campaigns.SendAsync(campaignId!);
await client.Campaigns.DeleteAsync(campaignId!);
```

## 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:

```csharp theme={null}
var query = new Dictionary<string, object?>
{
    ["channel"] = "sms",
    ["limit"] = 100,
};
var total = 0;

while (true)
{
    var page = await client.RequestAsync("GET", "/messages", query: query);

    total += page.GetProperty("data").GetArrayLength();
    var pagination = page.GetProperty("meta").GetProperty("pagination");

    if (!pagination.GetProperty("has_more").GetBoolean())
        break;

    query["cursor"] = pagination.GetProperty("cursor").GetString();
}

Console.WriteLine($"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 `OrbitError`:

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

```csharp theme={null}
using Orbit.Sdk.Errors;

try
{
    await client.Messages.SendSmsAsync("+14155552671", "...");
}
catch (OrbitRateLimitError e)
{
    if (e.RetryAfter is { } delay) await Task.Delay(delay);
}
catch (OrbitAuthenticationError)
{
    // rotate API key
}
catch (OrbitError e)
{
    logger.LogError("orbit: {Code} ({Status}) — {Message}", e.Code, e.StatusCode, e.Message);
}
```

Every non-GET request automatically carries an `Idempotency-Key` header (GUID); override it per call with your own stable key (`idempotencyKey: "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#net-core-scope-not-full-parity) — is reachable through `client.RequestAsync(method, path, ...)`. It returns the raw JSON body as a `JsonElement`. Fetch a segment by id:

```csharp theme={null}
var segment = await client.RequestAsync("GET", "/contacts/segments/seg_01hxyz");
var name = segment.GetProperty("data").GetProperty("name").GetString();
```

The escape hatch carries the same auth, retry, and error model as the typed methods — treat it as a first-class client, not a fallback `HttpClient`.

## Webhook signature verification

```csharp theme={null}
using Orbit.Sdk;
using Orbit.Sdk.Errors;

try
{
    var event_ = Webhooks.Verify(
        payload: requestBody,
        signature: Request.Headers["X-Orbit-Signature"],
        secret: Environment.GetEnvironmentVariable("ORBIT_WEBHOOK_SECRET"));
    // event_ is a JsonElement — match on event_.GetProperty("type"), etc.
}
catch (OrbitWebhookSignatureError)
{
    return BadRequest(); // do NOT respond 200
}
```
