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:

  1. Sends a POST to TokenUrl with a form body (application/x-www-form-urlencoded) carrying grant_type=client_credentials and the client's credentials.
  2. Reads the token from access_token and its lifetime in seconds from expires_in in the JSON response.
  3. 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_in reports.
  • A change to the Authorization settings 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.

FieldRequiredDefaultDescription
TokenUrlYesnoneThe identity provider's token endpoint.
ClientIdYesnoneThe client ID your identity provider issued for GAINS Connect.
ClientSecretYesnoneThe client secret. Never returned on a read.
ClientAuthenticationMethodNoCLIENT_SECRET_BASICCLIENT_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.
ScopeNononeSpace-separated scopes, sent as the scope form parameter.
AudienceNononeSent as the audience form parameter, for providers that require one.
AccessTokenPathNoaccess_tokenWhere the token is in the response, dot-separated, for a provider that nests it: data.jwt.
ExpiresInPathNoexpires_inWhere the lifetime in seconds is in the response, dot-separated.
FormNononeExtra 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.

FieldRequiredDefaultDescription
TokenUrlYesnoneThe identity provider's token endpoint.
ClientIdYesnoneThe client ID; it becomes the assertion's iss claim.
PrivateKeyYesnoneThe private key, PEM-encoded; see Private keys below. Never returned on a read.
KeyIdNononeIdentifies the certificate your provider verifies with; sent as kid in the assertion header.
SignatureAlgorithmNoRS256RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384 or ES512.
AudienceNoTokenUrlThe assertion's aud claim.
ScopeNononeSpace-separated scopes.
ScopeLocationNoFORMWhere the scope is sent: FORM as the scope form parameter, ASSERTION as a scope claim, or BOTH.
ScopeFormatNoSTRINGHow a scope claim is written: STRING ("restlets rest_webservices") or ARRAY (["restlets", "rest_webservices"]).
AssertionLifetimeSecondsNo300How long the assertion is valid, 1 to 3600 seconds.
IncludeSubjectClaimNofalsetrue adds a sub claim equal to ClientId, for an identity provider that requires one.
IncludeClientIdFormParameterNofalsetrue also sends client_id in the token request form.
AccessTokenPathNoaccess_tokenWhere the token is in the response, dot-separated.
ExpiresInPathNoexpires_inWhere the lifetime in seconds is in the response, dot-separated.
FormNononeExtra form parameters; see Extra parameters below.
ClaimNononeExtra 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 with openssl 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 RS256 to RS512 and PS256 to PS512; an EC key signs ES256 to ES512. A key that cannot sign with the chosen algorithm is refused when you save it.
  • PrivateKey takes up to 8,192 characters.
  • Write the line breaks as \n inside 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.pem

key.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, scope or iss, cannot be added. Use the matching field instead; the error names the parameter.
  • A name can have up to 122 characters in Claim and 123 in Form.
  • 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 example

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

  1. Register an application for GAINS Connect in your tenant, and note its Application (client) ID and your Directory (tenant) ID.
  2. Under Certificates & secrets, create a client secret and copy its value.
  3. Grant the application the application permission (app role) your receiving API expects, and give admin consent.
  4. 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
      }
    }
  }
]
  • Scope is 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 example

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

  1. Create an integration record with the client credentials grant enabled, and note its Client ID.
  2. 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.
  3. Deploy the RESTlet that receives the webhooks, and note its external URL.
  4. 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
      }
    }
  }
]
  • KeyId is the Certificate ID from the client credentials setup, not the certificate's file name.
  • Audience is left out, so the assertion's aud is the token URL, which NetSuite expects.
  • Replace 1234567 with 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 PrivateKey and KeyId together with PATCH.

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)"
}
errorUsual cause
invalid_clientWrong ClientId or ClientSecret, an expired secret, the wrong ClientAuthenticationMethod, or a certificate the provider does not hold for KeyId.
invalid_scopeScope names a scope the client is not allowed, or is written in the wrong place or form for the provider.
unauthorized_clientThe client is not allowed the client credentials grant.
cool_offThe token endpoint refused a recent request; the delivery is retried. Correcting the Authorization settings makes the next delivery try at once.
nullThe 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.


Did this page help you?