Notification Channel Overview Provides a set of APIs to enable services to push notifications towards client applications over webhooks. The size limit for all notifications is 1MB. This API should not be used alone. This is a supportive API for APIs like the Messaging API. For instance, you'll need to manage channels in order to receive messaging notifications. API Usage Guidelines This API assumes that clients are implemented in such a way that unknown attributes are ignored. The addition of new response attributes may occur at any time, for any API response, without prior notice. This is not considered a breach of backward compatibility. This API explicitly omits any attribute in its responses for which the value is undefined or null. A common example of this is the nextPageMarker on paginated list responses which is only included if there are one or more additional pages of data. Webhook Only POST webhooks on HTTPS endpoints are supported. Additionally, the URL provided must be reachable via port 443 as other ports are not supported. When creating a webhook channel, an OPTIONS request will be sent to the provided URI to validate if it's allowing POST requests. If the server returns a 410 Gone or a 404 Not Found on a request, the webhook channel will be automatically deleted. In the case where the response received is a server error (5XX), the webhook request will be retried up to 2 more times in quick succession before abandoning. Failures and Retries Webhook URL Becoming Invalid If the server returns a 410 Gone or a 404 Not Found on a request, the webhook channel will be marked as unreachable. The time at which it became unreachable can be found in the webhookChannelData.unreachableTimestamp field of the Notification channel response. While a webhook channel is marked as unreachable, notifications will continue to be sent to it. Once a 2xx HTTP response is received, the webhook channel will be marked as reachable again and the webhookChannelData.unreachableTimestamp field will be cleared. An unreachable webhook channel will be automatically deleted after a 7 day period. Webhook Request Failure In the case where the response received is a server error (5XX), the webhook request will follow the retry policy. Webhook Request Timeout A Webhook notification is considered incomplete or timed out if a response is not provided in 4,000 ms or less. In this case, the webhook notification request will follow the retry policy. Webhook Request Retry Policy In the event that a webhook request failed, the webhook request will be retried up to 2 more times in quick succession before it is abandoned. When a notification is retried it preserve the Notification Id, so the client can deduplicate the notifications. Authorization The channel ownership is based on the user and the OAuth client ID, both are provided via the access token. If for some reason you need to create a new client, for instance to rotate the client secret, ownership would be lost. In such a case, reach out to our support team if recreating channels with your new client is not a possibility. Note also that if your previous channel is not deleted, you might receive duplicate notifications if you reuse the same webhook URL, you might want to design accordingly. Networking and IP ranges The Notification Channel API will emit calls to webhooks using a variety of IP addresses. Make sure to allow GoToConnect IP Range Blocks in your firewall. Webhook Notification Signature If a sharedSecret is provided upon the creation of a webhook notification channel, published webhook notifications will be signed: the 'Digest', 'Signature-Input' and 'Signature' headers, the latter relying on the shared secret, will be present in the HTTP requests and shall be used to verify the source of the notifications, see this IETF draft for reference. The verification of the signature of a webhook notification can be executed in three steps: Verify the content digest. Verify the 'Signature-Input' header. Verify the signature. Content Digest Verification To verify the 'Digest' HTTP header (content digest), the verifier shall extract the request body as a byte array, hash the body using the SHA-256 algorithm and encode the resulting hash using java.util.Base64. A value computed in such a manner that does not match the value provided by the 'Digest' HTTP header would be an indicator of a corrupted payload. The following sample Java code illustrates the process of verifying the content digest. import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.Objects; public class ContentDigestValidator { private ContentDigestValidator() { // Helper class } public static boolean isValid(final byte[] body, final String digestHeaderValue) throws NoSuchAlgorithmException { final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); final byte[] sha256Digest = messageDigest.digest(body); final String computedDigest = "sha-256=" + Base64.getEncoder().encodeToString(sha256Digest); return Objects.equals(digestHeaderValue, computedDigest); } } 'Signature-Input' Header Verification To verify the 'Signature-Input' header, the verifier shall extract the Signature-Input HTTP header and verify its value. The 'Signature-Input' header shall be formatted as described here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-05#section-4.1. For instance, the 'Signature-Input' header that is sent with webhook notifications will have the following format: Signature-Input: sig1=("@request-target" "date" "digest");keyid="some-channel-id";alg="hmac-sha256";created={long epoch};expires={long epoch};nonce={random unique value} where: keyid shall be the channelId of the webhook channel and allow the verifier to retrieve the sharedSecret defined at the creation of the channel. alg shall provide the signature algorithm method. In this case, the algorithm shall be "hmac-sha256". created shall not be in the future. expires shall not be before created. nonce shall be a random unique value generated for this signature. These parameters improve security by ensuring a sufficiently strong algorithm and by avoiding replay or forgery attacks. These are application-specific requirements that can be validated as part of the process described in the reference IETF draft. Signature Verification The process to verify the signature, described in the reference IETF draft consists in generating a 'Signature Input String' using the information of the HTTP request, signing said string with the chosen signing algorithm using the key material chosen by the signer and comparing this to the value of the 'Signature' HTTP header. The following sample Java code illustrates the process of verifying the signature: import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.List; import java.util.Objects; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.servlet.http.HttpServletRequest; public class SignatureValidator { private final String sharedSecretKey; private final byte[] sharedSecret; public SignatureValidator(final String webhookChannelId, final String sharedSecret) { // webhook channel id will be leveraged as secret key (matching the 'keyid' signature parameter) this.sharedSecretKey = webhookChannelId; this.sharedSecret = sharedSecret.getBytes(StandardCharsets.UTF_8); } public boolean isValid(final HttpServletRequest request) throws NoSuchAlgorithmException, InvalidKeyException { // Multiple occurrences of headers should be canonicalized as described in // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-05#section-2.1.1 // For simplicity, the following code assumes that there is a single occurrence of the header // and a single entry in Dictionary Structured Header (otherwise one would have to loop over the // signature identifiers) final String[] signatureEntry = request.getHeader("Signature").split("=", 2); final String[] signatureInputEntry = request.getHeader("Signature-Input").split("=", 2); // Match signature identifier if (!Objects.equals(signatureEntry[0], signatureInputEntry[0])) { throw new IllegalArgumentException("Non-matching signature identifier"); } // Examine the signature parameters final String signatureParameters = signatureInputEntry[1]; // Determine the verification key material for this signature. if (!Objects.equals(sharedSecretKey, getStringParameter(signatureParameters, "keyid"))) { throw new IllegalArgumentException("Key material could not be retrieved"); } if (!Objects.equals("hmac-sha256", getStringParameter(signatureParameters, "alg"))) { throw new UnsupportedOperationException("This implementation only supports hmac-sha256"); } // Use the received HTTP message and the signature's metadata to recreate the signature input final String coveredContentList = signatureParameters.substring(0, signatureParameters.indexOf(";")); if (!coveredContentList.startsWith("(") || !coveredContentList.endsWith(")")) { throw new IllegalArgumentException("Invalid format for signature parameters"); } final List signatureInput = new ArrayList<>(); for (final String contentIdentifier : coveredContentList .substring(1, coveredContentList.length() - 1) .split(" ")) { if ("\"@request-target\"".equals(contentIdentifier)) { signatureInput .add((contentIdentifier + ": " + request.getMethod() + " " + request.getRequestURI() + (request.getQueryString() != null ? "?" + request.getQueryString() : "")) .toLowerCase()); } else { signatureInput.add(contentIdentifier + ": " + request.getHeader(contentIdentifier.substring(1, contentIdentifier.length() - 1))); } } signatureInput.add("\"@signature-params\": " + signatureParameters); // Generate the signature using the sharedSecretKey. final String algo = "HMACSHA256"; final SecretKeySpec secretKeySpec = new SecretKeySpec(sharedSecret, algo); final Mac mac = Mac.getInstance(algo); mac.init(secretKeySpec); final byte[] signatureBytes = mac.doFinal(String.join("\n", signatureInput).getBytes(StandardCharsets.US_ASCII)); final String computedSignature = ":" + Base64.getEncoder().encodeToString(signatureBytes) + ":"; return Objects.equals(signatureEntry[1], computedSignature); } /** * Parses an inner list to retrieve the parameter value associated to the provided key. * * Example: ("@request-target" "Date" ...);keyid="key-id";algid="alg-id" -> "key-id" */ private String getStringParameter(final String signatureParameters, final String parameterKey) { final String parameterValue = Arrays.stream(signatureParameters.split(";")) .filter(signatureParameter -> signatureParameter.startsWith(parameterKey + "=")) .findAny() .orElseThrow( () -> new IllegalArgumentException("Missing parameter with key: " + parameterKey)) .substring((parameterKey + "=").length()); if (!parameterValue.startsWith("\"") || !parameterValue.endsWith("\"")) { throw new IllegalArgumentException( "Unexpected format for String parameter with key: " + parameterKey); } return parameterValue.substring(1, parameterValue.length() - 1); } } WebSocket There is a limitation of 50 opened WebSocket channels for a given user. Any attempt to create an additional WebSocket channel beyond that will result in a 409 response. As such, it is critical for client applications to manage the lifecycle of the channels they create by deleting them as soon as they are no longer needed. The user will receive each notification in this form : { "event":"Notification", "eventId":0, "timestamp":"2019-07-25T17:32:33Z", "data":{ "source":"source", "type":"type", "timestamp":"2019-07-25T17:32:28Z", "contentVersion":"1.0", "content": { ... } } } Where: event is set to Notification. eventId is an identifier representing the event timestamp is the time when the notification is sent on the WebSocket. WebSocket Refresh Required Notification The WebSocket channel is about to expire, and should be refreshed to avoid it being deleted. { "event":"Notification", "eventId":154, "timestamp":"2019-07-25T17:32:33Z", "data": { "source":"notification-websocket", "type":"WEBSOCKET_REFRESH_REQUIRED", "timestamp":"2021-09-20T17:32:28Z", "content": { "remainingLifetime": 120 } } } The remainingLifetime is the time in seconds before the channel is deleted. WebSocket Service Is Terminating Notification The service instance which currently hosts the WebSocket channel is terminating, and a new channel should be created to replace the current one. The current WebSocket channel will not be deleted before the end of its lifetime, but it will not be possible to refresh it once the service termination has been engaged. The service instance only completes its termination once every WebSocket channel it hosts is gone. { "event":"Notification", "eventId":263, "timestamp":"2019-07-25T17:32:33Z", "data": { "source":"notification-websocket", "type":"WEBSOCKET_TO_BE_CLOSED", "timestamp":"2021-09-20T17:32:28Z", "content": { "remainingLifetime": 120 } } } The remainingLifetime is the time in seconds before the channel is deleted. WebSocket Keep-Alive The Notification Channel service implements an application-level keep-alive, so that a client can determine the liveliness of their WebSocket connection. Periodically, the client application can send a marco Notification. The correlationId must not be reused between marco requests. That application-level keep-alive is to be used for situations where socket events are not available to the application. It can be used to detect disconnections faster, rather than relying on TCP timeouts. It is preferable to rely on the socket events when possible. { "correlationId": 1, "command": "marco" } In turn, the service will respond with a polo response. { "code": 200, "correlationId": 1, "data": { "type": "polo", "receivedTimestamp": "2021-09-20T17:32:28.789Z", "acknowledgeTimeStamp": "2021-09-20T17:32:28.791Z" }, "error": {}, "errorModel": { "reference" : "7GfoXrRHXNtmVN3t1NQR2zkitgTjZ82e", "errorCode": "BAD_REQUEST", "message": "WebSocket did not respond as expected" } }