The secret
Each team has a single webhook secret — a 64-character hex string generated when the team is created. Find it under Settings → Webhook Secret. It is shown in full once and stored hashed thereafter, so copy it into your secret manager immediately.
Signing a request
The signature is HMAC-SHA256(body, secret), hex-encoded, sent in the X-Webhook-Signature header. The body is the raw request bytes — sign first, send the same bytes verbatim.
async function sign(body, secret) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sigBytes = await crypto.subtle.sign("HMAC", key, enc.encode(body));
return Array.from(new Uint8Array(sigBytes))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}import hashlib
import hmac
def sign(body: bytes, secret: str) -> str:
return hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()package opshift
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Sign(body, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
return hex.EncodeToString(mac.Sum(nil))
}use hmac::{Hmac, Mac};
use sha2::Sha256;
fn sign(body: &str, secret: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC accepts a key of any size");
mac.update(body.as_bytes());
hex::encode(mac.finalize().into_bytes())
}Sending the request
BODY='{"status":"up"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$OPSHIFT_SECRET" -hex | awk '{print $2}')
curl -X POST https://www.opshift.io/api/ping/your-ping-token \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIG" \
-d "$BODY"Empty bodies
Heartbeat pings without a body are accepted, in which case the signature is computed over the empty string. The SDKs handle this automatically.
Secret rotation
Rotating the secret writes a new value and keeps the previous one valid for a grace window. During the overlap, OpShift verifies signatures against either secret — so you can roll the new value through your fleet without downtime.
A leaked secret lets anyone create alerts or pings on your team. If you suspect compromise, rotate from Settings → Webhook Secret and redeploy with the new value. Don't wait for the grace window to expire before deploying.
Failure modes
- Missing header → 401 Unauthorized with
{ error: "Missing webhook signature" }. - Header present but signature mismatch → 401 Unauthorized.
- Signature computed over a different body than was sent (e.g. re-serialised JSON with different whitespace) → 401. Always sign and send the same bytes.