Guide to Go1 Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | The Go1 API: POST and PATCH https://gateway.go1.com/webhooks, with Api-Version: 2025-01-01 |
| Subscription model | A list of event_types per webhook. Some types can be registered on a master portal |
| Signature header | Go1-Signature: t=<Unix milliseconds>,v1=<hex> |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | {t}.{raw JSON body} |
| Secret | The webhook's secret_key, chosen by you, at most 64 characters. Without it, requests are unsigned |
| Other security | OAuth 2.0 client-credentials token in Authorization, and nine published source IPs |
| Replay window | Go1 says to reject timestamps outside your own tolerance, and sets no value |
| Response window | 10 seconds |
| Retries | Four, at 30 seconds, 15 minutes, 4 hours and 24 hours after the previous attempt |
| Rate limiting | 429 or 503 with Retry-After pauses delivery to the endpoint, up to 24 hours |
| Payload version | webhook_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.
| Event | Fires 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.add | A learning object is added to a portal's available content |
content.remove | A learning object is removed from a portal's offering |
content.update | Subscribed, parent-level content is updated, whether or not it is in the portal's curated Library |
content.decommission.pending | A decommissioning date is set on a learning object |
content.decommission | A 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-Signatureheader 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
tinto the signed string exactly as received. - Convert it only when comparing it with the current time, and compare with
Date.now()in Node ortime.time() * 1000in 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
429or503with aRetry-Afterheader 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. ARetry-Afterof0is 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.