---
title: "Instance webhooks"
description: "Receive creation, readiness, and termination notifications for iOS, Android, Xcode, and Gradle instances."
canonical_url: "https://docs.limrun.com/docs/reference/webhooks"
markdown_url: "https://docs.limrun.com/docs/reference/webhooks.md"
last_updated: "2026-11-14"
x_farming_labs_generated_preamble: true
---

# Instance webhooks
URL: /docs/reference/webhooks
LLM index: /llms.txt
Description: Receive creation, readiness, and termination notifications for iOS, Android, Xcode, and Gradle instances.

# Instance webhooks

Receive `instance.created`, `instance.ready`, and `instance.terminated` events for iOS simulators, Android emulators, Xcode sandboxes, and Gradle sandboxes. Each organization can configure one HTTPS endpoint. Limrun sends each event as a JSON POST request with an HMAC-SHA256 signature.

## Configuration

Only organization admins can manage webhook settings. Members, viewers, API keys, and instance tokens cannot read or change the configuration.

Prepare an HTTPS endpoint that accepts JSON `POST` requests and returns a `2xx` status after accepting an event. The steps below configure Limrun to send events to that endpoint.

1. Select your organization in the [Console](https://console.limrun.com/settings) and open **Settings → Webhooks**.
2. Enter your receiver's **Webhook URL**.
3. If your receiver requires authentication, enter the full **Authorization** header value, such as `Bearer your-token`. Leave it blank if no token is required.
4. To receive connection details with ready events, enable **Include instance details and access token in ready events**. This is off by default.
5. Select **Save webhook** and copy the signing secret. Use it in your receiver to [verify incoming events](#signatures).
6. Select **Send test event**. Confirm that your receiver accepts the signed [`webhook.ping`](#test-events) and returns a `2xx` response.

The signing secret lets your server verify that a message came from Limrun and was not modified. Limrun generates it when you create the webhook, change its URL, or rotate the secret. Each new secret is shown only once. The optional Authorization value is a separate credential that your receiver asks Limrun to send.

To change authentication, enter a replacement value or select **Remove saved Authorization value**. A blank field keeps the saved value when the URL is unchanged. Changing the URL clears the saved credential unless you enter a new value. Saved Authorization values are never displayed again. Select **Delete webhook** and confirm to remove the saved settings and stop new deliveries.

Ordinary saves preserve the signing secret. If you lose it or need to replace it, select **Rotate secret** and confirm, then copy the new secret to your receiver. Changing the URL also generates a new secret and turns off the ready-event instance details option until you enable it again for the new receiver.

Once delivery starts, all attempts use the same URL, Authorization value, signing secret, and payload. Editing or deleting the webhook does not cancel those deliveries. If you rotate the secret, allow for requests signed with the previous secret while those retries finish.

### Endpoint requirements

The endpoint must use HTTPS on port 443 with a public ASCII DNS hostname and a valid TLS certificate. URLs can contain paths and query parameters, but cannot contain embedded credentials or fragments. The maximum URL length is 2,048 bytes.

Limrun checks every resolved address before each delivery attempt. Private, loopback, link-local, and special-use addresses are rejected, including mixed public and private DNS answers. Redirects are not followed. Return response headers smaller than 16 KiB; the response body is ignored.

Saving validates the URL's format; it does not confirm that Limrun can reach your receiver. Use **Send test event** to check delivery. For local development, expose your receiver through a public HTTPS tunnel and configure the full route, such as `https://your-tunnel.example.com/webhook`. Opening that URL in a browser sends a `GET`, which does not test webhook delivery.

## Test events

**Send test event** sends a signed `webhook.ping` to the saved URL using its saved Authorization value. Save or discard pending changes before testing. The Console shows success for a `2xx` response, or an error for a rejected destination, DNS failure, connection failure, timeout, or non-`2xx` response.

```json
{
  "id": "43358036-d731-4acf-9492-127fa646167c",
  "type": "webhook.ping",
  "organizationId": "org_...",
  "occurredAt": "2026-10-16T12:00:00Z"
}
```

Test events contain no instance data or access tokens. They make one attempt with a 20-second timeout and no retries. Each test gets a new event ID. Verify its signature and return `2xx` without creating or updating an instance in your application.

The test API returns HTTP `200` with `delivered: false` when delivery fails. A configured webhook is required (`404` otherwise); delivery capacity limits return `429`. Test success describes that attempt only. Limrun does not store test results or return receiver response bodies.

## Events

Configure the endpoint before creating instances. All three event types are enabled for the organization; there is no event selector. Existing lifecycle history is not sent.

| Event | Meaning |
| --- | --- |
| `instance.created` | Limrun records that the instance has been assigned resources. It may still be starting. `occurredAt` contains its creation timestamp. |
| `instance.ready` | Limrun records the instance as ready to connect. |
| `instance.terminated` | Limrun records termination. Billing and physical cleanup may still be pending. |

Events describe instance state changes, not individual API requests. An instance that terminates during startup might never emit `instance.ready`. Reusing an existing instance with `reuseIfExists: true` does not trigger a new created or ready event just because you called create again. Use the API response if the reused instance is already ready.

Notifications can arrive late or be lost during an outage. For Xcode and Gradle, these events describe the sandbox's lifetime. Individual build results use [build webhooks](/docs/ci/build-logs-and-webhooks). Billing and registry operations are outside this webhook's scope.

### Payloads

Each lifecycle event contains these fields:

| Field | Meaning |
| --- | --- |
| `id` | Event identifier. Use it to deduplicate deliveries; retries keep the same ID. |
| `type` | One of the lifecycle event names above. |
| `organizationId` | Organization that owns the instance. |
| `instanceId` | Instance that changed. |
| `product` | `ios`, `android`, `xcode`, or `gradle`. |
| `occurredAt` | Time of the lifecycle change in RFC 3339 format, which can include fractional seconds. |

These examples show the default payloads. Ready events can also include an optional [instance object](#instance-details-on-ready-events).

<Tabs>
<Tab title="Created">

```json
{
  "id": "c03443c2-3c2d-54fb-9531-90f312a4f1ca",
  "type": "instance.created",
  "organizationId": "org_...",
  "instanceId": "ios_euna_...",
  "product": "ios",
  "occurredAt": "2026-10-16T09:59:00Z"
}
```

</Tab>
<Tab title="Ready">

```json
{
  "id": "127c0c0c-807f-5306-aa3c-0d0031518dde",
  "type": "instance.ready",
  "organizationId": "org_...",
  "instanceId": "ios_euna_...",
  "product": "ios",
  "occurredAt": "2026-10-16T10:00:00Z"
}
```

</Tab>
<Tab title="Terminated">

```json
{
  "id": "9d6bc112-a44e-53cb-8a87-358c3feab95b",
  "type": "instance.terminated",
  "organizationId": "org_...",
  "instanceId": "ios_euna_...",
  "product": "ios",
  "occurredAt": "2026-10-16T10:05:00Z",
  "reason": "UserRequested"
}
```

</Tab>
</Tabs>

Xcode instance IDs use the `sandbox_` prefix. Ignore product values your receiver does not handle.

`reason` appears on termination events and uses `UserRequested`, `InactivityTimeout`, `HardTimeout`, or `Unknown`. Treat unrecognized reasons as `Unknown`.

## Instance details on ready events

Enable **Include instance details and access token in ready events** in the Console to add an `instance` field to `instance.ready`. The corresponding configuration API field is `includeInstance`. Created, terminated, and test events do not include an instance object.

The object uses the public instance response shape for the event's `product`, with `metadata`, `spec`, and `status`. It reflects the instance when Limrun records readiness. The instance might have terminated by the time your receiver handles the event.

`instance.status.token` grants control of this instance in its organization. It is an instance access token, separate from the webhook signing secret and your receiver's Authorization value. Use a trusted receiver, keep the payload out of logs, and pass the token only to users allowed to control that instance. Deleting the webhook stops new deliveries but does not revoke tokens already delivered. Access to the running instance ends when it terminates.

For iOS and Android, `instance.status.endpointWebSocketUrl` and `instance.status.token` can supply the `url` and `token` props of [`RemoteControl`](/docs/platform/embed-simulator). Xcode and Gradle include `status.apiUrl` and `status.token` for their sandbox APIs. Optional `signedStreamUrl` and `streamToken` fields are omitted.

This iOS example shows the fields needed to connect. Other product-specific and optional fields can also be present:

```json
{
  "id": "127c0c0c-807f-5306-aa3c-0d0031518dde",
  "type": "instance.ready",
  "organizationId": "org_...",
  "instanceId": "ios_euna_...",
  "product": "ios",
  "occurredAt": "2026-10-16T10:00:00Z",
  "instance": {
    "metadata": {
      "id": "ios_euna_...",
      "organizationId": "org_...",
      "createdAt": "2026-10-16T09:59:00Z",
      "displayName": "Demo instance",
      "labels": { "session": "customer-session" }
    },
    "spec": { "region": "eu-north1", "inactivityTimeout": "3m0s" },
    "status": {
      "state": "ready",
      "apiUrl": "https://instance.example.com/v1/ios_euna_.../api",
      "endpointWebSocketUrl": "wss://instance.example.com/v1/ios_euna_.../endpointWebSocket",
      "token": "lim_st_..."
    }
  }
}
```

Create the iOS or Android instance with `wait=false` to return without waiting for readiness. Your backend receives and verifies the webhook, then forwards the instance to the authorized browser through your application's own connection. Correlate it with the session using `instanceId` or labels you set when creating the instance. The event can arrive before the create response, so your receiver must be ready first.

The browser can render it without another instance GET:

```jsx
import { RemoteControl } from '@limrun/ui';

// event is the verified ready event forwarded by your backend.
export function ReadyInstance({ event }) {
  if (event.type !== 'instance.ready' || !['ios', 'android'].includes(event.product)) return null;
  const status = event.instance?.status;
  if (!status?.endpointWebSocketUrl || !status.token) return null;
  return <RemoteControl url={status.endpointWebSocketUrl} token={status.token} />;
}
```

Webhook delivery remains best effort. Keep an API fallback if your application must recover from a missing ready event.

## Signatures

Each request includes an HMAC-SHA256 signature. Your server verifies it with the signing secret from the Console to confirm that Limrun sent the event and its contents have not changed.

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `Limrun-Event-Id` | Same event ID as the payload's `id`. |
| `Limrun-Signature` | `t=UNIX_SECONDS,v1=HEX_HMAC` |
| `Authorization` | Your saved header value, if configured. |

Compute HMAC-SHA256 over the signature header's timestamp, a dot, and the exact raw request body. Use the complete signing secret as the UTF-8 key, including the `whsec_` prefix. Do not Base64-decode it. Verify the signature before parsing JSON, and reject timestamps outside a five-minute tolerance to limit replay of captured requests.

The timestamp is the request's signing time, separate from the payload's `occurredAt`. Each retry gets a fresh timestamp and signature.

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(rawBody, signature, secret) {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(signature ?? "");
  if (!match) return false;
  const [, timestamp, digest] = match;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(timestamp + ".")
    .update(rawBody)
    .digest();
  return timingSafeEqual(expected, Buffer.from(digest, "hex"));
}
```

Pass the unmodified request bytes as `rawBody`. Parsing and reserializing JSON can change the signature input. For example, a Node.js route using the Web `Request` API can read the bytes before parsing:

```javascript
export async function POST(request) {
  const secret = process.env.LIMRUN_WEBHOOK_SIGNING_SECRET;
  if (!secret) throw new Error("LIMRUN_WEBHOOK_SIGNING_SECRET is required");

  const rawBody = Buffer.from(await request.arrayBuffer());
  if (!verifyWebhook(rawBody, request.headers.get("Limrun-Signature"), secret)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody.toString("utf8"));
  if (event.type === "webhook.ping") return new Response(null, { status: 204 });

  // Implement this in your app: deduplicate by event.id, then accept the event.
  await handleInstanceEvent(event);
  return new Response(null, { status: 204 });
}
```

Use `verifyWebhook` from the preceding example and set `LIMRUN_WEBHOOK_SIGNING_SECRET` to the secret copied from the Console. `handleInstanceEvent` represents your application logic; Limrun does not provide that function. If you configured an Authorization value, validate that header in your receiver too. Set a request body size limit in your server, and in frameworks with automatic JSON parsing, preserve the raw body before the parser runs.

## Delivery

Delivery is best effort. Return a `2xx` response promptly after accepting the event, and run lengthy work asynchronously. Limrun makes up to five attempts in total, including the first request, with a 20-second timeout per attempt. Timeouts, connection errors, and non-`2xx` responses count as failures, including `4xx` responses. Failed attempts are followed by waits of 1, 2, 4, and 8 seconds. A successful response stops retries. Each retry keeps the same event ID and payload.

Retries are held in memory. Service restarts, delivery capacity limits, and exhausted retries can cause notifications to be lost.

Duplicates and out-of-order delivery are possible. Store processed event IDs so duplicate notifications do not repeat your application's actions. A late created or ready event must not move an instance you already know has terminated back to a running state. If your application needs to confirm the current state, read the instance through the API.

There is no delivery history or manual replay API.

## Configuration API

These endpoints require a session belonging to a current organization admin. API keys cannot manage webhooks; use the Console to configure them.

| Method | Path | Behavior |
| --- | --- | --- |
| `PUT` | `/v1/organizations/{organizationId}/instance_webhook` | Accepts `url`, optional `authorization`, and optional `includeInstance`. Returns `url`, `hasAuthorization`, and `includeInstance`. Includes `signingSecret` only when creating the webhook or changing its URL. |
| `GET` | `/v1/organizations/{organizationId}/instance_webhook` | Returns `url`, `hasAuthorization`, and `includeInstance`, or `404` if no webhook is configured. |
| `DELETE` | `/v1/organizations/{organizationId}/instance_webhook` | Deletes the webhook and its credentials. |
| `POST` | `/v1/organizations/{organizationId}/instance_webhook/signing_secret` | Replaces the configured webhook's signing secret and returns `{ "signingSecret": "whsec_..." }`. |
| `POST` | `/v1/organizations/{organizationId}/instance_webhook/test` | Sends one test event to the saved endpoint. Returns `delivered` and `message`; includes `statusCode` if the receiver returned an HTTP response. |

The optional `authorization` field accepts up to 4,096 printable ASCII characters without surrounding whitespace. Omitting it preserves the saved value only when the URL is unchanged; sending an empty string removes it.

The optional `includeInstance` boolean enables the instance object and access token on ready events. Its default is `false`. Omitting it keeps the setting only when the URL is unchanged. Changing the URL resets it to `false` unless you explicitly send `true`. Deleting the webhook clears the setting.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
