OAuth 2.0 Webhook Destinations
Authenticate webhook deliveries with OAuth 2.0 client credentials or a private-key JWT, with Entra ID and NetSuite examples.
When the system receiving your webhooks is protected by an OAuth 2.0 identity provider, GAINS Connect can obtain an access token itself and send it with every delivery. This page explains the two OAuth 2.0 schemes of a webhook configuration's Authorization, how tokens are obtained and renewed, and how to set up two common identity providers. The rest of a webhook configuration is described in Webhooks. The other two schemes, Header and Basic, and how to choose between all four, are in Authorization.
How GAINS Connect obtains a token
Both OAuth 2.0 schemes use the client credentials grant. Before a delivery, GAINS Connect:
- Sends a
POSTtoTokenUrlwith a form body (application/x-www-form-urlencoded) carryinggrant_type=client_credentialsand the client's credentials. - Reads the token from
access_tokenand its lifetime in seconds fromexpires_inin the JSON response. - Sends the delivery with the header
Authorization: Bearer <token>.
POST /oauth2/token HTTP/1.1
Host: login.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic YzFmM2E5ZTJiN2Q4NGIwZjllNmEyZDVjOGI3ZjRlMTA6a1g5Mi1wUXI3LVp0MzE=
grant_type=client_credentials&scope=webhooks.write{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjNGOEMxIn0.eyJhdWQiOiJlcnAtYXBpIn0.c2lnbmF0dXJl",
"token_type": "Bearer",
"expires_in": 3600
}- A token is reused until shortly before it expires, as
expires_inreports. - A change to the
Authorizationsettings takes effect on the next delivery. A token obtained with the previous settings is not reused. - If your URL answers
401, GAINS Connect obtains a new token and sends the payload once more straight away. - If no token can be obtained, nothing is sent. The delivery is recorded in webhook history as failed with status code
502, and retried like any other failure (see Retries).
Use your provider's public https token endpoint as TokenUrl, at its final address: redirects are not followed. Never put credentials in it.
Saving a configuration checks the settings and, for OAuth2PrivateKeyJwt, the key, but does not contact your identity provider. To confirm a token can be obtained, use POST /webhook/test: when it cannot, the test answers 400 with the identity provider's error.
OAuth2ClientCredentials
The client authenticates to the token endpoint with a client ID and secret.
| Field | Required | Default | Description |
|---|---|---|---|
TokenUrl | Yes | none | The identity provider's token endpoint. |
ClientId | Yes | none | The client ID your identity provider issued for GAINS Connect. |
ClientSecret | Yes | none | The client secret. Never returned on a read. |
ClientAuthenticationMethod | No | CLIENT_SECRET_BASIC | CLIENT_SECRET_BASIC sends the ID and secret in an Authorization: Basic header of the token request; CLIENT_SECRET_POST sends them as client_id and client_secret in the form. |
Scope | No | none | Space-separated scopes, sent as the scope form parameter. |
Audience | No | none | Sent as the audience form parameter, for providers that require one. |
AccessTokenPath | No | access_token | Where the token is in the response, dot-separated, for a provider that nests it: data.jwt. |
ExpiresInPath | No | expires_in | Where the lifetime in seconds is in the response, dot-separated. |
Form | No | none | Extra form parameters, sent exactly as named; see Extra parameters below. |
{
"Authorization": {
"OAuth2ClientCredentials": {
"TokenUrl": "https://login.example.com/oauth2/token",
"ClientId": "c1f3a9e2b7d84b0f9e6a2d5c8b7f4e10",
"ClientSecret": "kX92-pQr7-Zt31",
"ClientAuthenticationMethod": "CLIENT_SECRET_BASIC",
"Scope": "webhooks.write",
"Audience": "https://erp.example.com/api"
}
}
}OAuth2PrivateKeyJwt
The client authenticates to the token endpoint with a JSON Web Token it signs with a private key, known as a client assertion. Your identity provider holds the matching certificate or public key, so no shared secret is sent.
| Field | Required | Default | Description |
|---|---|---|---|
TokenUrl | Yes | none | The identity provider's token endpoint. |
ClientId | Yes | none | The client ID; it becomes the assertion's iss claim. |
PrivateKey | Yes | none | The private key, PEM-encoded; see Private keys below. Never returned on a read. |
KeyId | No | none | Identifies the certificate your provider verifies with; sent as kid in the assertion header. |
SignatureAlgorithm | No | RS256 | RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384 or ES512. |
Audience | No | TokenUrl | The assertion's aud claim. |
Scope | No | none | Space-separated scopes. |
ScopeLocation | No | FORM | Where the scope is sent: FORM as the scope form parameter, ASSERTION as a scope claim, or BOTH. |
ScopeFormat | No | STRING | How a scope claim is written: STRING ("restlets rest_webservices") or ARRAY (["restlets", "rest_webservices"]). |
AssertionLifetimeSeconds | No | 300 | How long the assertion is valid, 1 to 3600 seconds. |
IncludeSubjectClaim | No | false | true adds a sub claim equal to ClientId, for an identity provider that requires one. |
IncludeClientIdFormParameter | No | false | true also sends client_id in the token request form. |
AccessTokenPath | No | access_token | Where the token is in the response, dot-separated. |
ExpiresInPath | No | expires_in | Where the lifetime in seconds is in the response, dot-separated. |
Form | No | none | Extra form parameters; see Extra parameters below. |
Claim | No | none | Extra claims added to the assertion; see Extra parameters below. |
{
"Authorization": {
"OAuth2PrivateKeyJwt": {
"TokenUrl": "https://login.example.com/oauth2/token",
"ClientId": "c1f3a9e2b7d84b0f9e6a2d5c8b7f4e10",
"PrivateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7...\n-----END PRIVATE KEY-----\n",
"KeyId": "gains-webhooks-2026",
"SignatureAlgorithm": "PS256",
"Scope": "webhooks.write",
"IncludeSubjectClaim": true
}
}
}Private keys
- Send an unencrypted PKCS#8 key in PEM form, the block starting
-----BEGIN PRIVATE KEY-----. A PKCS#1 key (BEGIN RSA PRIVATE KEY) is refused; convert it withopenssl pkcs8 -topk8 -nocrypt -in old.pem -out key.pem. A password-protected key (BEGIN ENCRYPTED PRIVATE KEY) cannot be read. - Match the key to the algorithm. An RSA key signs
RS256toRS512andPS256toPS512; an EC key signsES256toES512. A key that cannot sign with the chosen algorithm is refused when you save it. PrivateKeytakes up to 8,192 characters.- Write the line breaks as
\ninside the JSON string, as in the example above.
To create a key and a self-signed certificate to register with your identity provider:
openssl req -x509 -newkey rsa:3072 -nodes -days 730 \
-subj "/CN=gains-webhooks" -keyout key.pem -out certificate.pemkey.pem is the PrivateKey you send to GAINS Connect; certificate.pem is what you upload to your identity provider. Keep key.pem nowhere else.
Extra parameters
Some identity providers need a parameter or claim the fields above do not cover. Form adds form parameters to the token request, and Claim (for OAuth2PrivateKeyJwt only) adds claims to the assertion. Each is an object of names and text values, sent exactly as you write them:
{
"Form": {
"resource": "https://erp.example.com"
},
"Claim": {
"tenant": "acme-prod"
}
}- Names GAINS Connect sets itself, such as
grant_type,scopeoriss, cannot be added. Use the matching field instead; the error names the parameter. - A name can have up to 122 characters in
Claimand 123 inForm. - Values are always text. A claim is added as a JSON string.
Secrets on reads and updates
ClientSecret and PrivateKey follow the same rules as every webhook secret: never returned, kept by a PATCH that leaves them out; see Authorization.
To rotate a client secret, send only the new one:
{
"Authorization": {
"OAuth2ClientCredentials": {
"ClientSecret": "Hm4!wQ8z-Rt52"
}
}
}Create the new secret at your identity provider first, update GAINS Connect, and delete the old secret only once deliveries succeed with the new one. A secret that reads ******(re-enter) can no longer be used; see Receiving Webhooks.
Example: Microsoft Entra ID
Illustrative exampleThis example shows how the fields map to one identity provider's terms. Check your provider's current documentation and your own tenant's values before you use it.
Your receiving API is registered in Entra ID, and GAINS Connect is a client application with a client secret that is allowed to call it. Create the configuration with POST /webhook/settings:
- Register an application for GAINS Connect in your tenant, and note its Application (client) ID and your Directory (tenant) ID.
- Under Certificates & secrets, create a client secret and copy its value.
- Grant the application the application permission (app role) your receiving API expects, and give admin consent.
- Note the Application ID URI of your receiving API, for example
api://erp-webhooks.
[
{
"Name": "ERP via Entra ID",
"Active": true,
"Url": "https://erp.example.com/gains/webhooks",
"Authorization": {
"OAuth2ClientCredentials": {
"TokenUrl": "https://login.microsoftonline.com/2b7e4f1a-9c3d-4e8b-a5f6-0d1c2e3f4a5b/oauth2/v2.0/token",
"ClientId": "6a1d9c3e-4b2f-47a8-9e0d-5c7b8a6f3e21",
"ClientSecret": "Xq8~kP2.mN7_rT4-vW9zL3sJ6hG1fD5aB0cE",
"ClientAuthenticationMethod": "CLIENT_SECRET_POST",
"Scope": "api://erp-webhooks/.default"
}
},
"Triggers": {
"POSubmit": {
"Enabled": true
}
}
}
]Scopeis the API's Application ID URI followed by/.default. Entra ID then issues a token carrying the app roles you granted.- Your receiving API validates the token it receives: its issuer, its audience (your API) and the app role.
Example: Oracle NetSuite
Illustrative exampleThis example shows how the fields map to one identity provider's terms. Check NetSuite's current documentation and your own account's values before you use it.
NetSuite's OAuth 2.0 client credentials flow (machine to machine) authenticates the client with a signed assertion whose scope is a list inside the assertion, so it uses OAuth2PrivateKeyJwt with ScopeLocation ASSERTION and ScopeFormat ARRAY:
- Create an integration record with the client credentials grant enabled, and note its Client ID.
- In the OAuth 2.0 client credentials setup, map that integration to the role and user deliveries should run as, upload
certificate.pem, and note the Certificate ID NetSuite assigns. - Deploy the RESTlet that receives the webhooks, and note its external URL.
- Create the configuration with
POST /webhook/settings:
[
{
"Name": "NetSuite RESTlet",
"Active": true,
"Url": "https://1234567.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=1842&deploy=1",
"Authorization": {
"OAuth2PrivateKeyJwt": {
"TokenUrl": "https://1234567.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token",
"ClientId": "3e9f1c7a5b2d48e6a0c4f8b1d7e3a9c5f2b6d8e0a4c1f7b3e9d5a2c8f6b0e4d1",
"PrivateKey": "-----BEGIN PRIVATE KEY-----\nMIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQDF...\n-----END PRIVATE KEY-----\n",
"KeyId": "f3Hk8Qm2Zr6Wt1Yp9Lx4Nb7Vc0Js5Dg3Ae8Ru2Ko6Ti",
"SignatureAlgorithm": "PS256",
"Scope": "restlets",
"ScopeLocation": "ASSERTION",
"ScopeFormat": "ARRAY"
}
},
"Triggers": {
"POSubmit": {
"Enabled": true
}
}
}
]KeyIdis the Certificate ID from the client credentials setup, not the certificate's file name.Audienceis left out, so the assertion'saudis the token URL, which NetSuite expects.- Replace
1234567with your account ID in both URLs, as NetSuite shows it for your production or sandbox account. - Renew the certificate before it expires. Upload the new certificate, then send the new
PrivateKeyandKeyIdtogether withPATCH.
Errors
When you save a configuration, a problem with the OAuth 2.0 settings answers 400 with a message naming the field and what is wrong with it, for example Authorization.OAuth2ClientCredentials.ClientSecret is required.
When a token cannot be obtained at delivery time, webhook history records the delivery with status code 502 and the identity provider's answer in Response:
{
"tokenEndpointStatus": 401,
"error": "invalid_client",
"errorDescription": "Token request refused (HTTP 401 invalid_client: Client authentication failed)"
}error | Usual cause |
|---|---|
invalid_client | Wrong ClientId or ClientSecret, an expired secret, the wrong ClientAuthenticationMethod, or a certificate the provider does not hold for KeyId. |
invalid_scope | Scope names a scope the client is not allowed, or is written in the wrong place or form for the provider. |
unauthorized_client | The client is not allowed the client credentials grant. |
cool_off | The token endpoint refused a recent request; the delivery is retried. Correcting the Authorization settings makes the next delivery try at once. |
null | The token endpoint could not be reached, answered with an error of its own, or answered without a token at AccessTokenPath. errorDescription says which. |
See Receiving Webhooks for the other status codes webhook history records.
Updated about 9 hours ago
