Destinations
Destinations define where event notifications are delivered. When something happens to a device in your workspace — a connection, a status change, a command result — Texture can notify your systems in real time.
| Type | Delivery | Use case |
|---|---|---|
| Webhook | HTTP POST to your URL | Production integrations, automation pipelines |
| Email to an address | Alerts for ops teams, stakeholders |
Destination object#
{
"id": "dest_abc123def456",
"workspaceId": "ws_abc123def456",
"label": "Production Webhook",
"destinationType": "WEBHOOK",
"deviceType": "ALL",
"eventTypes": ["DEVICE_UPDATED", "DEVICE_CONNECTED"],
"url": "https://example.com/webhook",
"email": null,
"phoneNumber": null,
"secret": "whsec_my-webhook-secret",
"headers": { "X-Custom-Header": "value" },
"includeWeather": false,
"deactivated": false,
"deactivatedAt": null,
"deactivatedReason": null,
"createdAt": "2025-06-01T00:00:00.000Z",
"updatedAt": "2025-06-01T00:00:00.000Z"
}Event types#
| Event | Description |
|---|---|
COMMAND_FAILED | A device command failed |
COMMAND_SCHEDULED | A device command was scheduled |
COMMAND_SUCCEEDED | A device command succeeded |
COMMAND_OVERRIDDEN | A device command was overridden |
CONNECTION_FAILED | A device connection attempt failed |
CUSTOMER_CREATED | A customer was created |
CUSTOMER_DELETED | A customer was deleted |
CUSTOMER_UPDATED | A customer was updated |
DEVICE_CONNECTED | A device came online |
DEVICE_DISCONNECTED | A device went offline |
DEVICE_DISCOVERED | A new device was discovered |
DEVICE_UPDATED | Device data was updated |
ENROLLMENT_APPROVED | An enrollment was approved |
ENROLLMENT_REJECTED | An enrollment was rejected |
ENROLLMENT_SUBMITTED | An enrollment was submitted |
SITE_CREATED | A site was created |
SITE_DELETED | A site was deleted |
SITE_UPDATED | A site was updated |
ALERT_CREATED | An alert was created |
ALERT_UPDATED | An alert was updated |
Device type scopes#
Filter which device types trigger notifications:
| Scope | Description |
|---|---|
ALL | All device types |
BATTERIES | Battery storage systems |
CHARGERS | EV chargers |
SOLAR_INVERTERS | Solar inverters |
THERMOSTATS | Smart thermostats |
VEHICLES | Electric vehicles |
OTHER | Other device types |
UNKNOWN | Unknown device types |
List destinations#
GET /v1/destinations
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
perPage | integer | 20 | Items per page |
Response: 200 OK — Paginated list of destination objects.
Get destination#
GET /v1/destinations/:id
Response: 200 OK — Returns the destination object.
Create webhook destination#
POST /v1/destinations/webhooks
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
label | string | Yes | Human-readable label (1–255 chars) |
url | string | Yes | Webhook delivery URL (must be HTTPS) |
eventTypes | string[] | Yes | At least one event type |
deviceType | string | Yes | Device type scope |
secret | string | No | HMAC signing secret for signature verification. If omitted, Texture generates one for you. |
includeWeather | boolean | No | Include weather data in event payloads |
headers | object | No | Custom HTTP headers sent with each delivery |
{
"label": "Production Webhook",
"url": "https://example.com/webhook",
"eventTypes": ["DEVICE_UPDATED", "DEVICE_CONNECTED"],
"deviceType": "ALL",
"secret": "whsec_my-secret-for-hmac-verification",
"headers": { "X-Custom-Header": "value" }
}Response: 201 Created — Returns the created destination object.
Create email destination#
POST /v1/destinations/emails
| Field | Type | Required | Description |
|---|---|---|---|
label | string | Yes | Human-readable label (1–255 chars) |
email | string | Yes | Delivery email address |
eventTypes | string[] | Yes | At least one event type |
deviceType | string | Yes | Device type scope |
Response: 201 Created
Update destination#
PATCH /v1/destinations/:id
All fields are optional — provide at least one.
| Field | Type | Description |
|---|---|---|
label | string | New label (1–255 chars) |
eventTypes | string[] | New event types (min 1) |
deviceType | string | New device type scope |
url | string | New webhook URL (webhook only) |
email | string | New email (email only) |
secret | string | New signing secret (webhook only) |
includeWeather | boolean | Include weather data (webhook only) |
headers | object | New custom headers (webhook only) |
Response: 200 OK — Returns the updated destination object.
Delete destination#
DELETE /v1/destinations/:id
Response: 204 No Content
Toggle destination#
Enable or disable a destination without deleting it.
PUT /v1/destinations/:id/enabled
| Field | Type | Required | Description |
|---|---|---|---|
enabled | boolean | Yes | true to enable, false to disable |
Response: 200 OK — Returns the updated destination object.
Webhook signature verification#
When you configure a webhook destination with a secret, Texture signs every delivery so you can verify it came from us and hasn't been tampered with.
How it works#
- Texture computes an HMAC-SHA256 hash of
JSON.stringify(payload)using your destination'ssecret. - The resulting hex digest is sent in the
Texture-Signatureheader. - A
Texture-Timestampheader contains the Unix millisecond timestamp of when the request was sent.
Verifying signatures#
Compute the same HMAC on your end and compare it to the header value.
import crypto from "crypto";
function verifyWebhookSignature(rawBody, secret, signatureHeader) {
const computed = crypto
.createHmac("sha256", secret)
.update(rawBody) // Use the raw JSON body string — do not re-serialize
.digest("hex");
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(computed, "hex"),
Buffer.from(signatureHeader, "hex")
);
}
// In your Express handler:
app.post("/webhooks/texture", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["texture-signature"];
const timestamp = req.headers["texture-timestamp"];
if (!verifyWebhookSignature(req.body, YOUR_WEBHOOK_SECRET, signature)) {
return res.status(401).send("Invalid signature");
}
// Optionally check timestamp to prevent replay attacks
const ageMs = Date.now() - Number(timestamp);
if (ageMs > 5 * 60 * 1000) { // 5 minutes
return res.status(401).send("Webhook too old");
}
// Process the event
const event = JSON.parse(req.body);
console.log("Received event:", event.type);
res.status(200).send("OK");
});import hmac
import hashlib
import time
def verify_webhook_signature(raw_body: bytes, secret: str, signature_header: str) -> bool:
computed = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed, signature_header)
# In your Flask handler:
@app.route("/webhooks/texture", methods=["POST"])
def handle_webhook():
signature = request.headers.get("Texture-Signature")
timestamp = request.headers.get("Texture-Timestamp")
if not verify_webhook_signature(request.data, YOUR_WEBHOOK_SECRET, signature):
return "Invalid signature", 401
# Check timestamp freshness (timestamp is Unix milliseconds)
age_ms = int(time.time() * 1000) - int(timestamp)
if age_ms > 5 * 60 * 1000:
return "Webhook too old", 401
event = request.get_json()
print(f"Received event: {event['type']}")
return "OK", 200Important considerations#
- Use the raw request body for HMAC computation. Parsing and re-serializing JSON may change whitespace or key order, producing a different hash.
- Use constant-time comparison (
crypto.timingSafeEqualin Node.js,hmac.compare_digestin Python) to prevent timing attacks. - Check the timestamp to guard against replay attacks. The
Texture-Timestampheader is a Unix millisecond timestamp. Reject webhooks older than 5 minutes. - HTTP headers are case-insensitive. Your framework may lowercase the header name to
texture-signature. Check your framework's documentation.
Texture provides sample webhook applications in several languages in our open-source examples repository.
Testing webhooks locally#
Webhooks are sent from Texture's servers, so localhost URLs won't work. Use a tunneling tool like ngrok to expose your local server:
ngrok http 3000
# Use the ngrok URL as your webhook destination