Gareth Wilson Gareth Wilson

Guide to Go1 Webhooks: Features and Best Practices

Published


Go1 webhooks tell your learning platform what is happening in Go1: a learner starts or completes a course, an enrollment is deleted, or content is added, updated, retired or removed from a portal's library. If you integrate Go1's learning content into your own platform, webhooks are how completions and catalogue changes reach you as they happen rather than on a polling schedule.

This guide covers how Go1 webhooks are created and secured, the events and their envelope, Go1's retry and rate limiting behaviour, and the best practices for running a Go1 receiver in production.

What are Go1 webhooks?

A Go1 webhook is an API resource with a name, a URL and a list of event_types. You create it with an access token for the Go1 customer portal you want to hear from, and from then on Go1 sends each matching event to your URL as a JSON POST. Integrated partners that manage many customer portals can register some event types once on their master portal and receive them for every connected child portal.

Go1 offers three ways to secure a webhook, and recommends at least one: a signature with a secret_key you choose, OAuth 2.0 client-credentials authentication, or allowing only Go1's published IP addresses. This guide concentrates on the signature, which proves both the sender and the integrity of the body.

The signature header looks like Stripe's: Go1-Signature: t=<timestamp>,v1=<signature>. The difference that catches people out is that Go1's t is in milliseconds, and it must go into the signed string exactly as received.

Go1 webhook features

FeatureDetails
ConfigurationThe Go1 API: POST and PATCH https://gateway.go1.com/webhooks, with Api-Version: 2025-01-01
Subscription modelA list of event_types per webhook. Some types can be registered on a master portal
Signature headerGo1-Signature: t=<Unix milliseconds>,v1=<hex>
SchemeHMAC-SHA256, hex digest
Signed content{t}.{raw JSON body}
SecretThe webhook's secret_key, chosen by you, at most 64 characters. Without it, requests are unsigned
Other securityOAuth 2.0 client-credentials token in Authorization, and nine published source IPs
Replay windowGo1 says to reject timestamps outside your own tolerance, and sets no value
Response window10 seconds
RetriesFour, at 30 seconds, 15 minutes, 4 hours and 24 hours after the previous attempt
Rate limiting429 or 503 with Retry-After pauses delivery to the endpoint, up to 24 hours
Payload versionwebhook_version in the body. 3.0.0 is the only version

Common events

Go1 documents 13 event types in two families. Those marked with an asterisk can be registered once on a master portal and fire for every connected child portal.

EventFires when
enrollment.create*A user starts, or is assigned, a learning object
enrollment.complete*A user completes a learning object
enrollment.delete*An enrollment is deleted
content.addA learning object is added to a portal's available content
content.removeA learning object is removed from a portal's offering
content.updateSubscribed, parent-level content is updated, whether or not it is in the portal's curated Library
content.decommission.pendingA decommissioning date is set on a learning object
content.decommissionA learning object reaches its decommission date and stops accepting new enrollments
content.library.add*A learning object is added to a portal's curated Library
content.library.remove*A learning object is removed from a portal's curated Library
content.library.update*A learning object in a portal's curated Library is updated
content.library.decommission*A learning object in a portal's curated Library reaches its decommission date
content.library.sync.request*A user in Go1 for Admins requests a sync of their curated Library

content.add and content.remove are portal-scoped: a webhook on a master portal does not aggregate them from child portals, so configure them on each child portal you track. content.library.sync.request only fires if the webhook includes an integration_system_id, an identifier Go1 issues for your system. For new Go1 for Admins integrations, Go1 recommends the four content.library add, update, remove and decommission events as the default way to keep content in sync.

Every event shares one envelope. Here is the example from Go1's security docs:

{
  "id": "ABnJ5giCDEFG",
  "event_type": "enrollment.create",
  "webhook_version": "3.0.0",
  "sent": "2024-02-01T00:36:42.205Z",
  "attempt_number": 1,
  "url": "https://webhook.site/060778d7-072e-4b9a-b74d-72cc54d88086",
  "webhook_id": "5xwTAgL6GyEu",
  "data": {
    "id": "115799069",
    "lo_id": "36534258",
    "portal_id": "77180683",
    "event_time": "2024-02-01T00:36:42.000Z"
  }
}

id identifies the event and is the same on every retry, while attempt_number counts the attempts. sent is when this attempt went out, and data.event_time is when the event happened. The data object is small: IDs for the enrollment or content, the learning object (lo_id) and the portal. Fetch the full record from the Go1 API when you need it.

Setting up Go1 webhooks

Choose a secret of up to 64 characters and store it server-side, for example as GO1_WEBHOOK_SECRET. Then create the webhook with the portal's access token, including the secret as secret_key:

curl -X POST "https://gateway.go1.com/webhooks" \
  -H "Api-Version: 2025-01-01" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $GO1_ACCESS_TOKEN" \
  -d '{
    "name": "Course completion webhook",
    "url": "https://example.com/webhooks/go1",
    "event_types": ["enrollment.complete"],
    "secret_key": "'"$GO1_WEBHOOK_SECRET"'"
  }'

The access token decides which portal the webhook listens to. To listen across child portals, create the enrollment and library webhooks against your master portal. To add a secret to an existing webhook, or change it, update the webhook with PATCH https://gateway.go1.com/webhooks/{id}. The same call with {"status": "inactive"} turns a webhook off, and Go1 recommends doing that to test webhooks as soon as you no longer need them.

To use OAuth 2.0 instead of, or as well as, the signature, set auth_type to oauth2 and pass client credentials and your token endpoint in an oauth2 object. Go1 then fetches a token from your authorisation server, sends it in an Authorization: Bearer header, and caches it until it expires or your endpoint rejects it.

Securing Go1 webhooks

Split Go1-Signature on commas and then on =, and take t and v1. Concatenate t (unchanged, in milliseconds), a period and the raw body, compute an HMAC-SHA256 with your secret_key as the key, encode it as hex, and compare it with v1 in constant time.

const crypto = require('crypto');
const express = require('express');

function verifyGo1Signature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const parts = {};
  for (const part of signatureHeader.split(',')) {
    const [key, ...rest] = part.trim().split('=');
    parts[key] = rest.join('=');
  }
  const { t, v1 } = parts;
  if (!t || !/^\d+$/.test(t) || !v1) return false;

  // Sign t exactly as received. It is in milliseconds; do not convert it.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]))
    .digest('hex');

  const received = Buffer.from(v1.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/go1', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.GO1_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const header = req.get('go1-signature');
  if (!verifyGo1Signature(req.body, header, secret)) return res.sendStatus(401);

  // Optional freshness check. t is in MILLISECONDS, so compare it with Date.now().
  // const t = Number(/(?:^|,)\s*t=(\d+)/.exec(header)[1]);
  // if (Math.abs(Date.now() - t) > 5 * 60 * 1000) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, then route on event.event_type.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import re


def verify_go1_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not re.fullmatch(r"\d+", t) or not v1:
        return False
    signed = t.encode("utf-8") + b"." + raw_body  # t unchanged, in milliseconds
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1.lower().encode("utf-8"), expected.encode("utf-8"))


# In FastAPI: raw_body = await request.body()
# verify_go1_signature(raw_body, request.headers.get("go1-signature"),
#                      os.environ["GO1_WEBHOOK_SECRET"])

For Node, Go1 also publishes @go1/webhook-verifier-js, and says to use version 1.0.9 or later so that its timestamp tolerance handles millisecond timestamps correctly.

Go1's docs include a worked example you can test against. With the secret_key MyS3cretK#y and this exact body:

{"id":"ABnJ5giCDEFG","event_type":"enrollment.create","webhook_version":"3.0.0","sent":"2024-02-01T00:36:42.205Z","attempt_number":1,"url":"https://webhook.site/060778d7-072e-4b9a-b74d-72cc54d88086","webhook_id":"5xwTAgL6GyEu","data":{"id":"115799069","lo_id":"36534258","portal_id":"77180683","event_time":"2024-02-01T00:36:42.000Z"}}

the header is:

Go1-Signature: t=1706747802205,v1=a84cb2671c8c2215db331a18304ba98c89e8cc0f95e2e4eec4fa2673f877d786

Make Go1 webhooks production-ready. Hookdeck Event Gateway verifies the Go1-Signature header with its millisecond timestamp, and durably queues every enrollment and content event before your service sees it, so a burst of catalogue changes never overwhelms your handler.

Go1 webhook limitations and pain points

The timestamp is in milliseconds

The Problem: Verification fails, or a freshness check rejects every request as being from the distant future.

Why It Happens: Most t= signature schemes use seconds. Go1's t is a 13-digit millisecond value. Code copied from another provider tends to divide it by 1000 before signing, which breaks the signature, or compares it with a seconds clock, which breaks the tolerance check.

Workarounds:

  • Put t into the signed string exactly as received.
  • Convert it only when comparing it with the current time, and compare with Date.now() in Node or time.time() * 1000 in Python.
  • Test against the worked example above.

How Hookdeck Can Help: Hookdeck's Go1 source type signs t exactly as received and does not apply a timestamp tolerance, so the unit cannot trip verification.

Signing is optional

The Problem: Requests arrive with no Go1-Signature header, and anything that knows the URL could post to it.

Why It Happens: Go1 signs only webhooks created or updated with a secret_key.

Workarounds:

  • Always set a secret_key, and fail closed when the header is missing.
  • Combine it with Go1's IP allowlist where your network allows.

How Hookdeck Can Help: Set the same secret on your Hookdeck Go1 source and Hookdeck stops unsigned or tampered requests before they reach your code. If you didn't set a secret_key in Go1, leave verification off on the source.

Bursts of events

The Problem: Your endpoint slows down or falls over after a catalogue change.

Why It Happens: Go1 sends events in real time. Its own example: enable content.add, and if 10,000 learning objects become available to your portal, 10,000 events arrive in rapid succession.

Workarounds:

  • Subscribe only to the event types you use. Go1 explicitly advises against listening to all of them.
  • Process events asynchronously, so the request only queues work.
  • Answer 429 or 503 with a Retry-After header when you need Go1 to back off. Go1 then pauses delivery to the endpoint, including new events, until the time you give, capped at 24 hours. A Retry-After of 0 is ignored and the normal retry schedule applies.

How Hookdeck Can Help: Hookdeck absorbs the burst and delivers to your service at a maximum rate you choose, so you do not need to implement back-pressure yourself.

A short retry schedule

The Problem: An outage of more than a day loses events.

Why It Happens: Go1 waits 10 seconds for a 2xx, then retries 30 seconds, 15 minutes, 4 hours and 24 hours after each previous attempt. Go1 documents no attempt after the fifth, which comes a little over 28 hours after the first.

Workarounds:

  • Respond quickly and process in the background.
  • After a long outage, reconcile enrollments and content from the Go1 API.

How Hookdeck Can Help: Hookdeck acknowledges Go1 within the window and keeps every event, then delivers it once your service is back, with your own retry policy and manual replay.

Duplicates do happen

The Problem: An enrollment completion is recorded twice.

Why It Happens: Go1 says it makes every effort to send one payload per event, but that duplicates can occur in rare circumstances, and retries resend events your endpoint may already have processed.

Workarounds:

  • Deduplicate on the envelope id, which stays the same across retries.
  • Check your own state before acting. For content.add, Go1 suggests checking that the learning object is not already in your system.

How Hookdeck Can Help: Hookdeck's deduplication can drop repeats based on the id field before they reach your service.

Best practices

Verify against the raw body

Go1's worked example signs the compact JSON body as sent. In Express, mount express.raw() on the webhook route and parse only after verification. In Next.js, use await request.text(). In FastAPI, read await request.body().

Use master portal webhooks where you can

If you manage many customer portals, create the enrollment and library webhooks once on your master portal rather than per portal. It is simpler to manage and, as Go1 notes, reduces unnecessary traffic. Remember that content.add and content.remove still need a webhook on each child portal.

Route on event_type and key on data.portal_id

One endpoint can receive every event type from every portal. Switch on event_type, and use data.portal_id to find the customer the event belongs to.

Acknowledge fast and process asynchronously

Go1 asks for a 2xx within 10 seconds and recommends background processing to handle spikes. See why to process webhooks asynchronously and our guide to webhook idempotency.

Conclusion

Go1 signs webhooks only when you give them a secret_key, and then sends Go1-Signature: t=...,v1=..., a hex HMAC-SHA256 over the millisecond timestamp, a period and the raw body. Use t exactly as received, deduplicate on the envelope id, subscribe only to the events you need, and use Retry-After or a queue to survive the bursts that catalogue changes produce.

Hookdeck Event Gateway sits in front of your receiver, verifies every Go1 signature, and holds each event until your service has processed it, so bursts and outages cost latency rather than lost completions.

Get started with Hookdeck for free and handle Go1 webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.