# 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:

```json
{
  "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`:

```bash
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.

```javascript
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:

```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](/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](/docs/destinations#set-a-max-delivery-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](/docs/retries) 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](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Go1 webhooks reliably in minutes.