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.
- Select your organization in the Console and open Settings → Webhooks.
- Enter your receiver's Webhook URL.
- If your receiver requires authentication, enter the full Authorization header value, such as
Bearer your-token. Leave it blank if no token is required. - To receive connection details with ready events, enable Include instance details and access token in ready events. This is off by default.
- Select Save webhook and copy the signing secret. Use it in your receiver to verify incoming events.
- Select Send test event. Confirm that your receiver accepts the signed
webhook.pingand returns a2xxresponse.
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.
{
"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. 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.
{
"id": "c03443c2-3c2d-54fb-9531-90f312a4f1ca",
"type": "instance.created",
"organizationId": "org_...",
"instanceId": "ios_euna_...",
"product": "ios",
"occurredAt": "2026-10-16T09:59:00Z"
}{
"id": "127c0c0c-807f-5306-aa3c-0d0031518dde",
"type": "instance.ready",
"organizationId": "org_...",
"instanceId": "ios_euna_...",
"product": "ios",
"occurredAt": "2026-10-16T10:00:00Z"
}{
"id": "9d6bc112-a44e-53cb-8a87-358c3feab95b",
"type": "instance.terminated",
"organizationId": "org_...",
"instanceId": "ios_euna_...",
"product": "ios",
"occurredAt": "2026-10-16T10:05:00Z",
"reason": "UserRequested"
}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. 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:
{
"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:
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.
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:
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.
Receive creation, readiness, and termination notifications for iOS, Android, Xcode, and Gradle instances.
Was this guide helpful?