Webhooks

Webhooks let your own application receive a near-real-time notification whenever certain things
happen on your Assignr site — a game gets published, an official is changed on a game, a user is
added to your site, or an official is attached to a league. Instead of polling the API for changes,
Assignr sends an HTTP request to a URL you control as soon as the event happens.

This feature is aimed at developers integrating with Assignr's API. There's no in-app "Webhooks"
page or setting to turn on — creating a subscription through the API is itself what opts your site
in. As soon as you have an active subscription listing an event type, that event starts being sent;
deleting the subscription (or disabling it, or removing the event type) stops it. Optionally, a
subscription can also include criteria to narrow things down further — see
Scoping deliveries with criteria below.

Before you start

You'll need:

  1. An OAuth application with access to the webhooks scope (Step 1 below).
  2. A publicly reachable URL on your own server that can receive an HTTPS POST request and
    respond quickly (Assignr does not wait around for a slow endpoint).

Step 1: Get API access with the webhooks scope

Webhook subscriptions are created and managed through the Assignr API, so you need an OAuth
application first. If you don't have one, please contact Assignr Support. You will need to ask for the webhooks scope in order to access this functionality.

Your OAuth Application will have a client ID/secret — you'll use these to obtain an access token
the same way as any other Assignr API integration. Request the webhooks scope explicitly when
you authorize or request a token; it isn't granted by default.

Step 2: Create a subscription

There's no in-app form for the actual subscription — you create it with an API call, using the
access token from Step 1. Creating it is what turns the events on: as soon as this subscription
exists and is enabled, matching events start being sent to target_url.

POST https://api.assignr.com/api/v2/webhooks/subscriptions
FieldRequiredDescription
site_idYesThe site this subscription belongs to.
event_typesYesOne or more event type strings (see Event reference).
target_urlYesThe URL Assignr will POST to when a matching event fires.
target_methodYesThe HTTP method to use — use POST.
target_headersNoAn object of custom headers to include on every delivery (e.g. an API key your endpoint expects).
enabledNoDefaults to true. Set to false to pause deliveries without deleting the subscription.
criteriaNoAn object that narrows which occurrences of an event type get sent (e.g. only games in certain leagues). See Scoping deliveries with criteria.

Example request body:

{
  "site_id": 12345,
  "event_types": ["game.game.published", "game.official.changed"],
  "target_url": "https://example.com/webhooks/assignr",
  "target_method": "POST",
  "target_headers": { "X-My-App-Key": "abc123" },
  "criteria": { "league_id": [111, 222] }
}

A successful response (201) returns the subscription, including a generated secret you'll use
to verify deliveries:

{
  "id": 987,
  "site_id": 12345,
  "event_types": ["game.game.published", "game.official.changed"],
  "enabled": true,
  "secret": "bd957e7b-c79c-496b-8ba7-d023c31be30e",
  "target_method": "POST",
  "target_url": "https://example.com/webhooks/assignr",
  "target_headers": { "X-My-App-Key": "abc123" },
  "criteria": { "league_id": [111, 222] },
  "created": "2026-08-24T12:00:00Z"
}

Save the secret when you create the subscription — there's no separate screen to look it up
later
other than fetching the subscription again via the API (see below).

Managing an existing subscription

All of these also require the webhooks scope:

ActionRequest
List your subscriptionsGET /api/v2/webhooks/subscriptions
View one subscriptionGET /api/v2/webhooks/subscriptions/:id
Update a subscriptionPUT /api/v2/webhooks/subscriptions/:id (send only the fields you want to change)
Delete a subscriptionDELETE /api/v2/webhooks/subscriptions/:id

Pause deliveries temporarily with PUT .../subscriptions/:id and {"enabled": false}, rather than
deleting and recreating the subscription. The same call can add, change, or clear criteria.

Scoping deliveries with criteria

By default, a subscription's event_types gets every occurrence of that event for the site. The
optional criteria object lets you narrow that down, and what it accepts depends on the event type:

  • game.game.published and game.official.changedcriteria can include any of the game
    filter fields you'd use to search games, such as league_id, venue_id, game_type_id,
    age_group_id, gender_id, home_team_id, or away_team_id. Each can be a single value or an
    array of values. Only games matching the filter trigger a delivery; a subscription with no
    criteria (or an empty object) receives all games for the site.
  • site.league_official.created — the only key that matters is league_id (a single value or
    array). Set it to only be notified when an official is added for the first time to specific
    leagues. Without it, you're notified for every league on the site.
  • site.user.createdcriteria isn't applicable to this event; any value you set here is
    ignored for it.

A single subscription can list several event_types while sharing one criteria object — each
event type only looks at the keys that are meaningful to it and ignores the rest. Unrecognized keys
are silently ignored rather than rejected, so double-check spelling if a criteria filter doesn't
seem to be working as expected.

What you'll receive

Each delivery is a small JSON "pointer" — it tells you what happened and where to look, rather
than including the full record. Fetch the linked resource (with your own API access token) to get
full details.

Example delivery for game.game.published:

{
  "id": 55501,
  "topic": "game.game.published",
  "_links": {
    "self": { "resource-type": "webhook-event", "href": "https://api.assignr.com/api/v2/webhooks/events/55501.json" },
    "resource": { "resource-type": "game", "href": "https://api.assignr.com/api/v2/games/778899.json" },
    "owner": { "resource-type": "site", "href": "https://api.assignr.com/api/v2/sites/12345.json" },
    "game": { "resource-type": "game", "href": "https://api.assignr.com/api/v2/games/778899.json" }
  }
}

For game.official.changed, the resource link points at the affected assignment, and a
separate game link is included so you don't have to look up the assignment first to find its
game.

Verifying a delivery is genuinely from Assignr

Each delivery includes a signature header so you can confirm it wasn't spoofed:

X-Hook0-Signature: t=<unix_timestamp>,h=x-event-id x-event-type,v1=<hmac_sha256_hex>

To verify:

  1. Build the signed string: {timestamp}.x-event-id x-event-type.{event id}.{event topic}.{raw request body}.
  2. Compute an HMAC-SHA256 of that string, keyed with your subscription's secret.
  3. Compare it to the v1 value in the header (constant-time comparison recommended).
  4. Reject requests whose timestamp is too old, to guard against replay.

The request also includes X-Event-Id, X-Event-Type, Content-Type: application/json, and any
custom target_headers you configured on the subscription.

Sample verification code (Ruby)

require "openssl"
require "json"

module Assignr
  class WebhookVerificationError < StandardError; end

  module_function

  # raw_body:         the exact, unparsed request body (do not re-serialize parsed JSON)
  # signature_header:  the value of the X-Hook0-Signature header
  # secret:            the subscription's secret, from the API response when you created it
  def verify_webhook!(raw_body:, signature_header:, secret:, tolerance: 300)
    fields = signature_header.to_s.split(",").each_with_object({}) do |part, memo|
      key, value = part.split("=", 2)
      memo[key] = value
    end
    timestamp, header_list, signature = fields.values_at("t", "h", "v1")

    if timestamp.nil? || header_list.nil? || signature.nil?
      raise WebhookVerificationError, "Malformed signature header"
    end

    if (Time.now.to_i - timestamp.to_i).abs > tolerance
      raise WebhookVerificationError, "Timestamp outside of tolerance"
    end

    event = JSON.parse(raw_body)
    signed_payload = [timestamp, header_list, event["id"], event["topic"], raw_body].join(".")
    expected_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)

    unless secure_compare(expected_signature, signature)
      raise WebhookVerificationError, "Signature mismatch"
    end

    event
  end

  def secure_compare(a, b)
    a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
  end
end

Example use in a Rails controller:

class Webhooks::AssignrController < ActionController::API
  def create
    event = Assignr.verify_webhook!(
      raw_body: request.raw_post,
      signature_header: request.headers["X-Hook0-Signature"],
      secret: Rails.application.credentials.assignr_webhook_secret
    )

    case event["topic"]
    when "game.game.published"
      GamePublishedJob.perform_later(event)
    when "game.official.changed"
      GameOfficialChangedJob.perform_later(event)
    end

    head :ok
  rescue Assignr::WebhookVerificationError
    head :unauthorized
  end
end

A few things worth keeping in mind:

  • Verify against the raw, unparsed request body — re-serializing parsed JSON can change
    whitespace/key order and break the signature check.
  • OpenSSL.fixed_length_secure_compare raises if the two strings differ in length, so check
    bytesize first (as above) rather than letting a mismatched signature raise instead of just
    failing verification.
  • Reject old timestamps (the tolerance above) to guard against replayed requests.

Sample verification code (Python)

import hashlib
import hmac
import json
import time


class WebhookVerificationError(Exception):
    pass


def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> dict:
    """
    raw_body:          the exact, unparsed request body bytes (don't re-serialize parsed JSON)
    signature_header:  the value of the X-Hook0-Signature header
    secret:            the subscription's secret, from the API response when you created it
    """
    fields = dict(part.split("=", 1) for part in signature_header.split(","))
    timestamp = fields.get("t")
    header_list = fields.get("h")
    signature = fields.get("v1")

    if not timestamp or not header_list or not signature:
        raise WebhookVerificationError("Malformed signature header")

    if abs(int(time.time()) - int(timestamp)) > tolerance:
        raise WebhookVerificationError("Timestamp outside of tolerance")

    event = json.loads(raw_body)
    signed_payload = ".".join([
        timestamp,
        header_list,
        str(event["id"]),
        event["topic"],
        raw_body.decode("utf-8"),
    ])
    expected_signature = hmac.new(
        secret.encode("utf-8"), signed_payload.encode("utf-8"), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected_signature, signature):
        raise WebhookVerificationError("Signature mismatch")

    return event

Example use in a Flask endpoint:

from flask import Flask, request, abort

app = Flask(__name__)
ASSIGNR_WEBHOOK_SECRET = "bd957e7b-c79c-496b-8ba7-d023c31be30e"  # from your app's config/secrets


@app.route("/webhooks/assignr", methods=["POST"])
def assignr_webhook():
    try:
        event = verify_webhook(
            raw_body=request.get_data(),
            signature_header=request.headers.get("X-Hook0-Signature", ""),
            secret=ASSIGNR_WEBHOOK_SECRET,
        )
    except WebhookVerificationError:
        abort(401)

    if event["topic"] == "game.game.published":
        handle_game_published(event)
    elif event["topic"] == "game.official.changed":
        handle_game_official_changed(event)

    return "", 200

hmac.compare_digest is already constant-time, so there's no separate secure_compare helper
needed here.

Event reference

Event typeFires whenCriteria support
game.game.publishedA game is published, if the game is public.Game filter fields (e.g. league_id, venue_id)
game.official.changedAn official is added, removed, or replaced on a public game that hasn't happened yet.Game filter fields (e.g. league_id, venue_id)
site.user.createdA user is added to your site.Not applicable
site.league_official.createdAn official is attached to a league on your site for the first time.league_id only

Limitations

  • There's currently no delivery log in the app — no list of past deliveries, response codes, or a
    redelivery button. If you suspect deliveries are failing, double-check your endpoint is
    reachable and returning a fast response, or contact Assignr support.
  • target_method isn't restricted in the API to a specific list of values, but POST is the
    supported and expected method — other values aren't guaranteed to work.

Frequently asked questions

I created a subscription but I'm not receiving an event.
Check that: the subscription's event_types includes that event type, enabled is true on the
subscription, and — if you set criteria — the specific game, league, or user actually matches it
(see Scoping deliveries with criteria).

Can I subscribe to events for more than one site with one subscription?
No — a subscription is tied to a single site_id. Create one subscription per site.

I lost my subscription's secret. Can I get it back?
Yes — fetch the subscription with GET /api/v2/webhooks/subscriptions/:id; the secret is returned
in the response.