{"id":2933,"date":"2026-08-31T11:13:31","date_gmt":"2026-08-31T05:43:31","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2933"},"modified":"2026-08-31T11:23:45","modified_gmt":"2026-08-31T05:53:45","slug":"oauth-messaging-connect-customer-sms-account","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/oauth-messaging-connect-customer-sms-account\/","title":{"rendered":"OAuth for Messaging Platforms: Letting Customers Connect Their Own SMS Account"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">If your product sends SMS on behalf of your customers, stop asking them to paste an API key. This is a full engineering guide to the SMSGatewayCenter REST OAuth API: the authorization code flow with PKCE, token lifetimes, the refresh response that returns an empty refresh token, the multi-tenant token store, revocation on disconnect, and the one semantic that breaks most first integrations, which is that the access token does not carry account identity.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/oauth-messaging-connect-customer-sms-account.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/oauth-messaging-connect-customer-sms-account-1024x584.webp\" alt=\"Diagram showing a partner application exchanging an authorization code for an access token with a messaging platform across a multi-tenant OAuth flow.\" class=\"wp-image-2934\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/oauth-messaging-connect-customer-sms-account-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/oauth-messaging-connect-customer-sms-account-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/oauth-messaging-connect-customer-sms-account-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/oauth-messaging-connect-customer-sms-account.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">OAuth moves the credential out of your database and into a grant the customer can revoke, but only if the token store is designed for it.<\/figcaption><\/figure>\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<h2 class=\"wp-block-heading\">TABLE OF CONTENTS<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#four-ways-to-authenticate\">Four Ways to Authenticate, and Why Only One of Them Belongs in a Product<\/a><\/li>\n\n\n\n<li><a href=\"#token-does-not-carry-identity\">The Finding That Breaks First Integrations: The Token Does Not Carry Identity<\/a><\/li>\n\n\n\n<li><a href=\"#why-api-key-pasting-is-worse\">Why Asking Customers to Paste an API Key Is Worse Than It Looks<\/a><\/li>\n\n\n\n<li><a href=\"#the-flow-end-to-end\">The Flow End to End<\/a><\/li>\n\n\n\n<li><a href=\"#pkce\">PKCE, and Why a Confidential Client Should Still Use It<\/a><\/li>\n\n\n\n<li><a href=\"#the-state-parameter\">The <code>state<\/code> Parameter Is Not Optional Here<\/a><\/li>\n\n\n\n<li><a href=\"#redirect-uri-exact-matching\">Redirect URI Exact Matching<\/a><\/li>\n\n\n\n<li><a href=\"#what-the-token-endpoint-returns\">What the Token Endpoint Actually Returns<\/a><\/li>\n\n\n\n<li><a href=\"#two-incompatible-expiry-formats\">Two Token Systems, Two Incompatible Expiry Formats<\/a><\/li>\n\n\n\n<li><a href=\"#the-empty-refresh-token\">Refresh Semantics: The Empty Refresh Token<\/a><\/li>\n\n\n\n<li><a href=\"#proactive-versus-reactive-refresh\">Proactive Versus Reactive Refresh<\/a><\/li>\n\n\n\n<li><a href=\"#introspection-is-a-recovery-path\">Introspection Is a Recovery Path, Not a Per Request Lookup<\/a><\/li>\n\n\n\n<li><a href=\"#the-token-store-schema\">The Token Store Is a Schema Problem<\/a><\/li>\n\n\n\n<li><a href=\"#encryption-at-rest\">Encryption at Rest, and What That Phrase Has to Mean<\/a><\/li>\n\n\n\n<li><a href=\"#making-authenticated-calls\">Making Authenticated Calls: Token Plus Identity<\/a><\/li>\n\n\n\n<li><a href=\"#the-error-table\">The Error Table and What <code>invalid_grant<\/code> Actually Means<\/a><\/li>\n\n\n\n<li><a href=\"#revocation-on-disconnect\">Revocation on Disconnect<\/a><\/li>\n\n\n\n<li><a href=\"#revoke-versus-logout\">Revoke and Logout Are Not the Same Endpoint<\/a><\/li>\n\n\n\n<li><a href=\"#three-integration-archetypes\">The Three Integration Archetypes<\/a><\/li>\n\n\n\n<li><a href=\"#reference-implementation-nodejs\">Reference Implementation: Node.js<\/a><\/li>\n\n\n\n<li><a href=\"#reference-implementation-python\">Reference Implementation: Python<\/a><\/li>\n\n\n\n<li><a href=\"#testing-an-oauth-integration\">Testing an OAuth Integration Without Sending Messages<\/a><\/li>\n\n\n\n<li><a href=\"#observability-for-the-connection\">Observability for the Connection Itself<\/a><\/li>\n\n\n\n<li><a href=\"#ten-step-build-order\">Ten Step Build Order<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#pre-launch-checklist\">Pre Launch Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\">Ten Mistakes That Cost Real Money<\/a><\/li>\n\n\n\n<li><a href=\"#what-this-article-does-not-claim\">What This Article Deliberately Does Not Claim<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you are building a product that sends SMS on behalf of your customers, and you are currently asking them to paste an API key into a settings field, replace that with OAuth. SMSGatewayCenter publishes a <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/oauth-api\/\">REST OAuth API<\/a> implementing the OAuth 2.0 authorization code flow with PKCE. Your customer clicks Connect, signs in on a page you never see, and your application receives an access token that expires in 3600 seconds along with a refresh token valid for 30 days. You store the tokens, not the customer&#8217;s password, and the customer can disconnect at any time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is one semantic that will break your first integration if you do not plan for it. The access token does not carry account identity. The documentation states verbatim that &#8220;OAuth does not replace the SMS API request format.&#8221; You must send the token in the <code>Authorization<\/code> header and separately send <code>userid<\/code> as a request parameter on every single SMS API call. The token response gives you that value in its <code>username<\/code> field. If you do not persist <code>username<\/code> alongside the tokens, your integration will authenticate successfully and then fail to send, and the error you get back will not obviously point at the cause.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rest of this article is the implementation.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The authorization endpoint is <code>GET https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/authorize<\/code>. Token, revoke and introspect are POST endpoints under the same <code>\/rest\/oauth\/v1\/<\/code> prefix.<\/li>\n\n\n\n<li>PKCE with <code>S256<\/code> is the documented challenge method. Use it even though your server side client is confidential.<\/li>\n\n\n\n<li>Access token lifetime is 3600 seconds. Refresh token lifetime is 30 days. The authorization code is valid for 10 minutes.<\/li>\n\n\n\n<li><strong>On refresh, the response comes back with <code>refresh_token<\/code> empty and the original refresh token remains valid.<\/strong> If your code blindly overwrites the stored refresh token with the response value, you will write an empty string over a working credential and disconnect the customer 3600 seconds later.<\/li>\n\n\n\n<li>Store <code>username<\/code> from the token response. Send it as <code>userid<\/code> on every SMS API request alongside the bearer token.<\/li>\n\n\n\n<li>Client registration is manual through the <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">contact page<\/a>. There is no self service client creation endpoint.<\/li>\n\n\n\n<li>The platform exposes four distinct authentication mechanisms in total. Three of them are wrong for a multi-tenant product for reasons this article makes concrete.<\/li>\n\n\n\n<li>Revocation uses <code>POST \/rest\/oauth\/v1\/revoke<\/code>. That is a different endpoint from <code>POST \/SMSApi\/logout<\/code>, which invalidates a bearer token issued by the separate Basic Auth mechanism. They are not interchangeable.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"four-ways-to-authenticate\" class=\"wp-block-heading\">Four Ways to Authenticate, and Why Only One of Them Belongs in a Product<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most articles about SMS API authentication stop at &#8220;use an API key&#8221;. The platform actually documents four distinct mechanisms, and they have genuinely different security properties. Getting this choice right at the start is cheaper than migrating a token store later.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Mechanism<\/th><th>How the credential travels<\/th><th>Expires<\/th><th>Third party can hold it safely<\/th><th>Documented at<\/th><\/tr><\/thead><tbody><tr><td><code>userid<\/code> plus <code>password<\/code><\/td><td>Two form fields in the request body<\/td><td>Never<\/td><td>No. This is the customer&#8217;s account password.<\/td><td><a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/authentication\/\">Authentication<\/a><\/td><\/tr><tr><td><code>apiKey<\/code> HTTP header<\/td><td>A single long lived header value<\/td><td>Never<\/td><td>No, for reasons in the next section<\/td><td><a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-api-key\/\">Create API Key<\/a><\/td><\/tr><tr><td>Basic Auth to <code>\/me\/<\/code>, then Bearer<\/td><td>Base64 of <code>username:password<\/code> once, then a session token<\/td><td>Yes, <code>expires_after<\/code> timestamp<\/td><td>No. Obtaining the token requires the password.<\/td><td><a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/authentication\/\">Authentication<\/a><\/td><\/tr><tr><td>REST OAuth 2.0 with PKCE<\/td><td>Authorization code, then access and refresh tokens<\/td><td>Yes, 3600 seconds and 30 days<\/td><td><strong>Yes. This is what it is for.<\/strong><\/td><td><a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/oauth-api\/\">REST OAuth API<\/a><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The first three all share one property: the credential either is the customer&#8217;s password or can only be obtained by holding the customer&#8217;s password. That is the disqualifying property for a multi-tenant product. The fourth is the only mechanism where a customer can grant you access, see what they granted, and take it away without changing their password and breaking every other integration they have.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Basic Auth mechanism deserves a note because it looks superficially like OAuth and is not. You POST to <code>https:\/\/unify.smsgateway.center\/me\/<\/code> with an <code>Authorization: Basic<\/code> header containing base64 of <code>username:password<\/code>, and you get back a token:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"token\": \"YaFljBZQUQbTLkeOHIYpNX_hxvh-XwP1rGK52qbhxB0Ny...\",\n    \"expires_after\": \"2023-08-19 14:26:38\",\n    \"statusCode\": \"200\",\n    \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That is a session token, not a delegated grant. It expires, which is good, but you had to be given the password to get it, which is the whole problem. Use this mechanism for your own first party services where you legitimately hold your own credentials. Do not use it to hold someone else&#8217;s.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Decision in One Sentence<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If the account being sent from belongs to somebody other than the entity writing the code, use OAuth. If it is your own account and your own server, an <code>apiKey<\/code> header is fine and simpler.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"token-does-not-carry-identity\" class=\"wp-block-heading\">The Finding That Breaks First Integrations: The Token Does Not Carry Identity<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is worth its own section because it is unusual and because it is now confirmed in two independent places in the documentation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In most OAuth deployments the access token is self describing. The resource server decodes or introspects it, learns which account it belongs to, and that is the end of the matter. Here it is not. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/oauth-api\/\">REST OAuth API documentation<\/a> states verbatim that &#8220;OAuth does not replace the SMS API request format.&#8221; Concretely this means an authenticated send looks like this, with two separate pieces of information doing two separate jobs:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST 'https:\/\/unify.smsgateway.center\/SMSApi\/send' \\\n  -H 'Authorization: Bearer ACCESS_TOKEN_FROM_TOKEN_RESPONSE' \\\n  -H 'Content-Type: application\/x-www-form-urlencoded' \\\n  --data-urlencode 'userid=USERNAME_FROM_TOKEN_RESPONSE' \\\n  --data-urlencode 'sendMethod=quick' \\\n  --data-urlencode 'msgType=text' \\\n  --data-urlencode 'senderid=SNDRID' \\\n  --data-urlencode 'mobile=919999999999' \\\n  --data-urlencode 'msg=Hello from a connected account' \\\n  --data-urlencode 'output=json'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The bearer token proves you are allowed to act. The <code>userid<\/code> parameter says whose account to act on. Both are required.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The same pattern shows up independently in the Basic Auth section of the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/authentication\/\">authentication page<\/a>, where the documented send sample carries a full bearer token in the header and still passes <code>userid<\/code> as a form field. Two different token systems, same requirement. Treat it as a platform wide rule rather than an OAuth quirk: <strong>on this API, a token is an authorization, never an identity.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The practical consequence for your schema is immediate. The token response is the only place <code>username<\/code> is handed to you:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"access_token\": \"your-sms-api-key\",\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"refresh_token\": \"opaque-refresh-token-value\",\n  \"username\": \"your-user-id\",\n  \"scope\": \"\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you persist only <code>access_token<\/code> and <code>refresh_token<\/code>, that <code>username<\/code> is gone the moment the response object is garbage collected, and every subsequent send fails. Persist it. It is a column, not a variable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a second observation worth making about that response. The <code>access_token<\/code> value is described in the sample as <code>your-sms-api-key<\/code>. Treat that as a hint that the access token is handled by the same machinery as an API key rather than as a promise about its internal structure. Do not attempt to parse it, do not assume it is a JWT, and do not assume it is safe to log a prefix of it as a correlation identifier.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"why-api-key-pasting-is-worse\" class=\"wp-block-heading\">Why Asking Customers to Paste an API Key Is Worse Than It Looks<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The usual objection to API key pasting is a soft one about user experience. There is a harder objection, and it comes from reading two documentation pages together.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-api-key\/\">Create API Key<\/a>. You POST to <code>https:\/\/unify.smsgateway.center\/SMSApi\/apikey\/create<\/code> with <code>userid<\/code> and <code>password<\/code>, and the response is this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"api\": \"apikey\",\n        \"action\": \"create\",\n        \"status\": \"success\",\n        \"msg\": \"ApiKey Created successfully.\",\n        \"code\": \"200\"\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note what is not there. The key itself is not returned by the create call. You cannot provision a key for a customer and hand it back to them in the same request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Second, <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/apikey\/read<\/code>, which does return it, in plaintext:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"apikey\",\n    \"action\": \"read\",\n    \"status\": \"success\",\n    \"msg\": \"success\",\n    \"code\": \"200\",\n    \"count\": 1,\n    \"apikeyList\": {\n      \"apikey\": \"902be2ba73523ek3k32c5f0b5f8f684337f710bc4\"\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Read those two facts together and the conclusion is unavoidable. The API key is retrievable given the account credentials, which means it is stored in a recoverable form rather than as a one way hash. It never expires on its own. It authorizes everything the account can do, including reading the account&#8217;s own key. So an API key sitting in your database is a password equivalent with no expiry and no scoping, and the customer&#8217;s only revocation mechanism is to rotate it, which breaks every other integration they have at the same time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two smaller parsing notes while we are here, because they will bite somebody. In the <code>apikey\/read<\/code> response, <code>apikeyList<\/code> is a JSON <strong>object<\/strong>, not an array, which is inconsistent with the <code>...List<\/code> fields on most other endpoints on this API such as <code>report_smsSummaryList<\/code> and <code>reports_dlrList<\/code>, which are arrays of single key wrapper objects. If you wrote a generic envelope unwrapper that assumes every <code>*List<\/code> key is an array, this endpoint will throw. And <code>count<\/code> is <code>1<\/code> here even though there is no array to count.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of this makes the API key mechanism wrong. It makes it wrong <strong>for holding somebody else&#8217;s credential<\/strong>. For your own backend, calling your own account, an <code>apiKey<\/code> header is the right answer and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/create-use-sms-gateway-center-api-key\/\">API key creation guide<\/a> covers the control panel path.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-flow-end-to-end\" class=\"wp-block-heading\">The Flow End to End<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Four HTTP interactions, in order. Nothing here departs from standard OAuth 2.0, which is the point: you can use an off the shelf client library, and the value of this section is in the parameters that are required here but optional elsewhere.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-flow-end-to-end.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-flow-end-to-end.webp\" alt=\"Diagram about oAuth Flow End to End\" class=\"wp-image-2936\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-flow-end-to-end.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-flow-end-to-end-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-flow-end-to-end-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1: Redirect the Browser to Authorize<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>GET https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/authorize\n  ?response_type=code\n  &amp;client_id=YOUR_CLIENT_ID\n  &amp;redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback\n  &amp;state=RANDOM_OPAQUE_VALUE\n  &amp;code_challenge=BASE64URL_SHA256_OF_VERIFIER\n  &amp;code_challenge_method=S256<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Optional parameters: <code>scope<\/code>, and <code>prompt<\/code>. Setting <code>prompt=login<\/code> forces a fresh sign in rather than reusing an existing session, which matters when a customer is connecting a second account from the same browser and would otherwise silently reconnect the first one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A valid request returns the sign in page with HTTP 200. That is worth knowing when you are debugging, because a 200 with an HTML body is the success case for this endpoint, not a failure to redirect.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If two factor authentication is enabled on the account, whether OTP or Google Authenticator, it is honoured on this page. Your application does not need to handle it and should not try to.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2: Receive the Callback<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The customer signs in and approves. The browser lands on your redirect URI with two query parameters:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>https:&#47;&#47;app.example.com\/oauth\/callback?code=SHORT_LIVED_CODE&amp;state=RANDOM_OPAQUE_VALUE<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The code is valid for 10 minutes. In practice you should exchange it within seconds. Ten minutes is the outer bound, not a budget to spend.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3: Exchange the Code for Tokens<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST 'https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/token' \\\n  -H 'Content-Type: application\/x-www-form-urlencoded' \\\n  --data-urlencode 'grant_type=authorization_code' \\\n  --data-urlencode 'client_id=YOUR_CLIENT_ID' \\\n  --data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \\\n  --data-urlencode 'code=SHORT_LIVED_CODE' \\\n  --data-urlencode 'redirect_uri=https:\/\/app.example.com\/oauth\/callback' \\\n  --data-urlencode 'code_verifier=THE_ORIGINAL_VERIFIER'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note that <code>redirect_uri<\/code> is sent again here even though no redirect happens. That is standard OAuth and exists to bind the token request to the same client registration that received the code. Send exactly the same string you sent in step 1.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4: Call the API<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Covered above. Bearer token in the header, <code>userid<\/code> in the body, on every request.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"pkce\" class=\"wp-block-heading\">PKCE, and Why a Confidential Client Should Still Use It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">PKCE, defined in <a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc7636\" target=\"_blank\" rel=\"noopener nofollow\">RFC 7636<\/a>, was originally designed for public clients such as mobile apps that cannot keep a client secret. Your server side integration can keep a secret, so the classic argument does not apply directly. Use it anyway, and the documentation treats <code>S256<\/code> as the method to use.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reason is that PKCE defends against a class of attack that has nothing to do with whether you can store a secret. The authorization code travels through the user&#8217;s browser, which means it passes through the address bar, browser history, any referrer header, and potentially any browser extension or proxy in between. If an attacker captures that code, a client secret does not stop them if they can also reach your token endpoint or, more realistically, if the code can be replayed against a differently configured client. PKCE binds the code to a secret that only ever existed in your server&#8217;s memory and was never sent through the browser.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Generating the pair correctly is the only place people get this wrong:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import base64, hashlib, secrets\n\ndef make_pkce_pair() -&gt; tuple&#91;str, str]:\n    # 32 random bytes gives a 43 character verifier, the RFC 7636 minimum.\n    verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b\"=\").decode(\"ascii\")\n    digest = hashlib.sha256(verifier.encode(\"ascii\")).digest()\n    challenge = base64.urlsafe_b64encode(digest).rstrip(b\"=\").decode(\"ascii\")\n    return verifier, challenge<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three details in five lines, each of which has broken a real integration somewhere:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The encoding is base64<strong>url<\/strong>, not standard base64. Standard base64 produces <code>+<\/code> and <code>\/<\/code>, which change meaning in a query string.<\/li>\n\n\n\n<li>The padding <code>=<\/code> characters are stripped. Some servers tolerate them, and relying on tolerance is not engineering.<\/li>\n\n\n\n<li>The SHA-256 is computed over the <strong>ASCII characters of the encoded verifier<\/strong>, not over the original random bytes. Hashing the raw bytes produces a challenge that will never match, and the failure surfaces at the token endpoint as a generic invalid grant, several steps away from the actual bug.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The verifier must be stored server side, keyed to the same session as the <code>state<\/code> value, and must survive the round trip through the browser without ever being sent to the browser. A signed, httpOnly, short lived cookie holding a session key that points at a server side record is the usual shape. Putting the verifier itself in a cookie defeats the purpose.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-state-parameter\" class=\"wp-block-heading\">The <code>state<\/code> Parameter Is Not Optional Here<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In much OAuth documentation <code>state<\/code> is described as recommended. In this API it is listed as required, which is the better default and saves an argument.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Its job is CSRF protection. Without it, an attacker can complete an authorization flow against their own SMSGatewayCenter account, capture the resulting <code>code<\/code>, and then trick a logged in user of your product into visiting your callback URL with that code attached. Your server exchanges it, stores the tokens, and now your victim&#8217;s workspace is quietly connected to the attacker&#8217;s messaging account. Every message your victim&#8217;s application sends goes out through, and is visible to, the attacker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The mitigation is mechanical. Generate a high entropy random value, store it server side bound to the current authenticated session, and on callback compare it with a constant time comparison and then delete it so it cannot be replayed. If the value does not match, or is absent, or has already been consumed, abort and show an error. Do not &#8220;handle it gracefully&#8221; by proceeding.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One extra point specific to a multi-tenant product: bind the state record to the tenant, not just to the user session. If a user belongs to several workspaces, the state record is what tells your callback which workspace the resulting token belongs to. Deriving that from a cookie or from &#8220;the last workspace they viewed&#8221; produces a race that shows up as tokens landing in the wrong tenant, which is a data leak, not a bug report.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"redirect-uri-exact-matching\" class=\"wp-block-heading\">Redirect URI Exact Matching<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The documented requirement is exact match. That is stricter than prefix matching and stricter than what several other providers do, and it has consequences you should design around rather than discover:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A trailing slash is a different URI. <code>https:\/\/app.example.com\/oauth\/callback<\/code> and <code>https:\/\/app.example.com\/oauth\/callback\/<\/code> are two different registrations.<\/li>\n\n\n\n<li>Adding a query parameter to the redirect breaks the match. If you were planning to pass your own tenant identifier as <code>?tenant=42<\/code> on the callback URL, you cannot. Put it in <code>state<\/code> instead, or in a server side record keyed by <code>state<\/code>.<\/li>\n\n\n\n<li>Your local development URL and your production URL are two registrations. Since client registration is manual through the <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">contact page<\/a>, request both up front rather than filing a second ticket during your integration sprint.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The tenant identifier point catches people repeatedly. The instinct is to make the callback URL carry the context. With exact matching, the only channel available for carrying context through the redirect is <code>state<\/code>, and since you are already storing a server side record keyed by <code>state<\/code> for the PKCE verifier, put the tenant identifier in that same record.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-the-token-endpoint-returns\" class=\"wp-block-heading\">What the Token Endpoint Actually Returns<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"access_token\": \"your-sms-api-key\",\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"refresh_token\": \"opaque-refresh-token-value\",\n  \"username\": \"your-user-id\",\n  \"scope\": \"\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Field by field, with what each one obliges you to do:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Field<\/th><th>Type<\/th><th>What you must do with it<\/th><\/tr><\/thead><tbody><tr><td><code>access_token<\/code><\/td><td>String, opaque<\/td><td>Store encrypted. Send as <code>Authorization: Bearer<\/code> or as the <code>apikey<\/code> header. Never log it, never parse it.<\/td><\/tr><tr><td><code>token_type<\/code><\/td><td>String, <code>Bearer<\/code><\/td><td>Assert it equals <code>Bearer<\/code> and fail loudly otherwise. Do not hardcode the header prefix without checking.<\/td><\/tr><tr><td><code>expires_in<\/code><\/td><td>Integer, seconds<\/td><td>Convert to an absolute UTC instant at the moment of receipt and store that, not the relative number.<\/td><\/tr><tr><td><code>refresh_token<\/code><\/td><td>String, opaque<\/td><td>Store encrypted. <strong>See the refresh section: this comes back empty on refresh.<\/strong><\/td><\/tr><tr><td><code>username<\/code><\/td><td>String<\/td><td>Store it. This is the <code>userid<\/code> you must send on every API call.<\/td><\/tr><tr><td><code>scope<\/code><\/td><td>String, empty in the sample<\/td><td>Do not build behaviour on it. See below.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">On <code>scope<\/code>, be careful. The documentation describes it only as an &#8220;optional scope string if configured for your client&#8221;, and the sample response shows it empty. That is not enough to build a permission model on. Do not write code that reads the scope string and decides whether the user is allowed to send WhatsApp messages or read reports. If you need to know what an account can do, find out by calling the relevant API and handling the failure, or by asking during onboarding. Treating an undocumented and empty scope field as an authorization decision is how you build a feature that silently stops working when the field starts being populated.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-incompatible-expiry-formats\" class=\"wp-block-heading\">Two Token Systems, Two Incompatible Expiry Formats<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is a small thing that produces a specific and annoying class of bug, so it is worth naming explicitly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The OAuth token endpoint expresses expiry as <code>\"expires_in\": 3600<\/code>, an integer count of seconds, relative to the moment the response was generated. The separate Basic Auth mechanism expresses expiry as <code>\"expires_after\": \"2023-08-19 14:26:38\"<\/code>, an absolute human readable string with no timezone offset and no ISO 8601 formatting. And elsewhere on the same platform, the OTP endpoints express time as millisecond epochs delivered as quoted strings, and the by transaction delivery report expresses it as strings like <code>\"July 13, 2019 03:44:48\"<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Four time formats across one API. The response is not to complain about it but to normalise at the boundary, once, into a single internal representation. Absolute UTC instants, stored as <code>timestamptz<\/code>, computed at the moment the response is received:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from datetime import datetime, timedelta, timezone\n\ndef absolute_expiry(expires_in_seconds: int, received_at: datetime | None = None) -&gt; datetime:\n    \"\"\"Convert a relative expires_in into an absolute UTC instant.\n\n    Compute this from the time the response ARRIVED, not from the time you\n    got round to processing it. On a slow queue those differ by enough to\n    matter when the lifetime is only 3600 seconds.\n    \"\"\"\n    now = received_at or datetime.now(timezone.utc)\n    return now + timedelta(seconds=int(expires_in_seconds))<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>received_at<\/code> parameter is not decoration. Make the clock injectable so you can write a test that asserts refresh happens at the right moment without waiting an hour.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A related caution about <code>\"expires_after\": \"2023-08-19 14:26:38\"<\/code>. Its timezone is not stated anywhere in the documentation. If you use the Basic Auth mechanism for your own first party services, do not guess. Either treat that value as advisory and refresh reactively on the first 401, or ask support what the timezone is before you build a proactive refresh on top of it. Guessing UTC when the value is actually IST puts your refresh five and a half hours late, and the symptom is a nightly batch that fails for exactly one window every day.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-empty-refresh-token\" class=\"wp-block-heading\">Refresh Semantics: The Empty Refresh Token<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the single highest value paragraph in this article, so it gets its own section and a warning box&#8217;s worth of emphasis.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Refreshing looks conventional:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST 'https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/token' \\\n  -H 'Content-Type: application\/x-www-form-urlencoded' \\\n  --data-urlencode 'grant_type=refresh_token' \\\n  --data-urlencode 'client_id=YOUR_CLIENT_ID' \\\n  --data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \\\n  --data-urlencode 'refresh_token=STORED_REFRESH_TOKEN'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>On refresh, the <code>refresh_token<\/code> field in the response comes back empty, and your original refresh token remains valid.<\/strong> This API does not do refresh token rotation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now consider the natural way to write the storage step, which is what almost everybody writes first:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># WRONG. This destroys a working credential.\ntokens = refresh(stored.refresh_token)\nstore.update(\n    access_token=tokens&#91;\"access_token\"],\n    refresh_token=tokens&#91;\"refresh_token\"],   # this is \"\" on a refresh response\n    expires_at=absolute_expiry(tokens&#91;\"expires_in\"]),\n)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That code works perfectly in testing, because your first token exchange is an authorization code exchange, which does return a refresh token. It fails in production one hour after the first refresh, because you have written an empty string over the only credential that can get you a new access token, and the customer now has to reconnect. Worse, it fails silently and asymmetrically: the customer who connected today is fine, the customer who connected yesterday is broken, and you will spend a while looking for something environmental.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The correct shape keeps the old value unless a genuinely new one arrives:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def merge_refresh_token(existing: str, from_response: str | None) -&gt; str:\n    \"\"\"Never let an absent or empty response value clobber a working token.\n\n    The token endpoint returns refresh_token only on the authorization_code\n    exchange. On a refresh_token grant it comes back empty and the original\n    stays valid.\n    \"\"\"\n    candidate = (from_response or \"\").strip()\n    return candidate if candidate else existing<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two design notes on that function. It strips whitespace before testing, because an empty string and a string containing a space are the same thing semantically and different things to a truthiness check. And it takes the existing value as an argument rather than reading it from a global store, so it is trivially unit testable, which matters because this is exactly the kind of logic that gets refactored by somebody who does not know why it is shaped that way. Write a test named something unambiguous like <code>test_empty_refresh_token_in_response_does_not_clobber_stored_token<\/code>, so the next person to touch it sees the intent in the failure output.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This pattern generalises. It is the same defensive move required elsewhere on this API where the literal string <code>\"0\"<\/code> is used as a null sentinel rather than as a value, for example in the <code>transactionId<\/code> returned by the OTP endpoints. Empty and sentinel values coming back from an API should be normalised at the boundary before they ever reach your persistence layer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The 30 Day Clock Is a Product Problem, Not Just a Technical One<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The refresh token is valid for 30 days. Because there is no rotation, that 30 days appears to run from issuance rather than from last use, though the documentation does not state which. Plan for the stricter reading: assume the clock started when the customer connected and does not reset.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The consequence is that a customer who connects and then does not send anything for a month comes back to a broken connection. This is common in seasonal businesses, in products where messaging is a secondary feature, and in trial accounts. Two mitigations, and you want both:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Refresh on a schedule rather than only on demand, so a dormant connection stays warm.<\/li>\n\n\n\n<li>Detect the failure and tell the customer in your own product&#8217;s language, before they discover it during a send. The knowledge base entry on <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/why-did-my-connection-stop-working-with-a-login-or-token-error\/\">connections that stop working with a login or token error<\/a> describes the customer facing symptom of exactly this class of failure in the context of other connected accounts, and it is a good model for the message you should be surfacing.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"proactive-versus-reactive-refresh\" class=\"wp-block-heading\">Proactive Versus Reactive Refresh<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Reactive refresh means you call the API, get an authentication failure, refresh, and retry. Proactive refresh means you check the stored expiry before making the call and refresh first if it is close.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For most APIs reactive refresh is fine and simpler. For a messaging API it is not, and the reason is specific to this domain.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A send request is not idempotent by default. If you fire a batch of ten thousand recipients, get an authentication failure, refresh, and retry, you have to be certain the first attempt did not partially succeed. On this API you cannot be certain, because an authentication failure returned after the request was accepted is indistinguishable at the client from one returned before. This is the same ambiguous outcome problem that governs retry design generally, and it is treated at length in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">SMS API retry strategy guide<\/a> and in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">message idempotency guide<\/a>. The short version is that retrying a send after an ambiguous failure is how you bill a customer twice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So: refresh proactively, before the send, and treat a mid batch authentication failure as a terminal ambiguous outcome rather than as something to retry through.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A workable policy:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Condition<\/th><th>Action<\/th><\/tr><\/thead><tbody><tr><td>Remaining lifetime greater than 600 seconds<\/td><td>Proceed with the call<\/td><\/tr><tr><td>Remaining lifetime 600 seconds or less<\/td><td>Refresh first, then call<\/td><\/tr><tr><td>Refresh fails with <code>invalid_grant<\/code><\/td><td>Mark the connection disconnected, notify the customer, do not retry<\/td><\/tr><tr><td>Refresh fails with a transport error<\/td><td>Retry the refresh with backoff. Refresh is safe to retry, sends are not.<\/td><\/tr><tr><td>Authentication failure on a send despite a fresh token<\/td><td>Treat the send outcome as unknown. Reconcile, do not retry.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The 600 second threshold is a choice, not a documented value. Pick it so that it comfortably exceeds the longest single request your system makes, including a slow batch submission, so a token cannot expire during a request that started valid.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A scheduled sweep completes the picture. Once an hour, select every connection whose access token expires within the next two hours or whose refresh token was last exercised more than a few days ago, and refresh it. This costs one cheap HTTP call per active tenant per hour and eliminates an entire category of first-request-of-the-morning failures.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"introspection-is-a-recovery-path\" class=\"wp-block-heading\">Introspection Is a Recovery Path, Not a Per Request Lookup<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The API provides <code>POST https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/introspect<\/code>, which takes a <code>token<\/code> parameter and returns:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\"active\": true, \"username\": \"your-user-id\", \"token_type\": \"Bearer\"}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The temptation is to call this before every send to check the token is still good. Do not. It doubles your request count, adds latency to the critical path, and tells you nothing your own stored expiry does not already tell you more cheaply. Your database knows when the token expires because you computed and stored the absolute instant when you received it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Introspection earns its place in exactly three situations:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Reconciling after an incident.<\/strong> Your token store was restored from a backup, or a deployment rolled back over a migration, and you do not trust your own expiry values. Introspect every connection to find out which are actually live.<\/li>\n\n\n\n<li><strong>Recovering <code>username<\/code>.<\/strong> If you shipped a version that failed to persist <code>username<\/code>, introspection returns it. That is a genuine migration path, and a good reason not to panic if you have already made that mistake.<\/li>\n\n\n\n<li><strong>Support and debugging.<\/strong> A customer says the connection is broken. One introspect call distinguishes &#8220;the grant was revoked on the provider side&#8221; from &#8220;our refresh job is not running&#8221;, and those have completely different remediations.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Build it, expose it in your internal admin tooling, and keep it off the send path.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-token-store-schema\" class=\"wp-block-heading\">The Token Store Is a Schema Problem<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Everything above turns into columns. This is where OAuth integrations quietly go wrong, because the flow is standard and well documented while the storage is left to each implementer.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-token-store-is-a-schema-problem.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-token-store-is-a-schema-problem.webp\" alt=\"Diagram about Token Store Schema Problem\" class=\"wp-image-2935\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-token-store-is-a-schema-problem.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-token-store-is-a-schema-problem-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-token-store-is-a-schema-problem-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE messaging_connection (\n    id                  BIGSERIAL PRIMARY KEY,\n\n    -- Which of YOUR customers this belongs to.\n    tenant_id           BIGINT      NOT NULL REFERENCES tenant(id),\n\n    -- Which of THEIR accounts this is. From the token response.\n    -- TEXT, never numeric. See the note below on identifier types.\n    provider_username   TEXT        NOT NULL,\n\n    -- Encrypted at rest. See the next section on what that has to mean.\n    access_token_enc    BYTEA       NOT NULL,\n    refresh_token_enc   BYTEA       NOT NULL,\n    key_version         SMALLINT    NOT NULL DEFAULT 1,\n\n    -- Absolute instants, computed at receipt. Never store the relative seconds.\n    access_expires_at   TIMESTAMPTZ NOT NULL,\n    refresh_expires_at  TIMESTAMPTZ NOT NULL,\n\n    -- Operational state, so a broken connection is queryable, not inferred.\n    status              TEXT        NOT NULL DEFAULT 'active',\n    last_refreshed_at   TIMESTAMPTZ,\n    last_refresh_error  TEXT,\n    connected_at        TIMESTAMPTZ NOT NULL DEFAULT now(),\n    disconnected_at     TIMESTAMPTZ,\n\n    CONSTRAINT messaging_connection_status_ck\n        CHECK (status IN ('active', 'needs_reauth', 'revoked', 'error')),\n\n    -- One tenant may connect several provider accounts, but not the\n    -- same provider account twice. Reconnecting must update, not insert.\n    CONSTRAINT messaging_connection_uniq\n        UNIQUE (tenant_id, provider_username)\n);\n\n-- The sweep query. Partial index so the scan stays cheap as the table grows.\nCREATE INDEX messaging_connection_due_refresh_idx\n    ON messaging_connection (access_expires_at)\n    WHERE status = 'active';<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The decisions embedded in that DDL, each of which is load bearing:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>provider_username<\/code> is <code>TEXT<\/code>.<\/strong> Nothing on this API should be stored as a numeric type just because it currently looks like a number. This is a hard won rule elsewhere on the platform: transaction identifiers are nineteen digit values that exceed the safe integer range in JavaScript, so any path that runs a response through a naive numeric conversion corrupts them silently. A username is less likely to be numeric, but the rule is cheaper to apply universally than to apply selectively and be wrong once.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expiry is absolute, and there are two of them.<\/strong> Storing <code>expires_in<\/code> is storing a number that is already wrong by the time it reaches disk. Storing only the access token expiry loses the ability to warn a customer before the 30 day refresh window closes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>status<\/code> is an explicit column with a check constraint, not something you infer.<\/strong> &#8220;Is this connection healthy&#8221; should be a <code>WHERE<\/code> clause, not a computation across three nullable timestamps. When a refresh fails with <code>invalid_grant<\/code>, you set <code>status = 'needs_reauth'<\/code> and the customer facing banner is a straightforward query. Inferring health from timestamps means every consumer reimplements the inference and one of them gets it wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The unique constraint is on the pair, not on either column alone.<\/strong> A tenant may legitimately connect two provider accounts, for instance a production account and a test account, which is exactly the situation covered for other providers in the knowledge base entry on <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/can-i-connect-more-than-one-airtable-account\/\">connecting more than one account<\/a>. And two of your tenants must never share one row even if by some accident they connect the same provider account. Making reconnection an upsert on this constraint is also what stops a customer clicking Connect twice from producing two rows, one of which then goes stale and starts failing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>key_version<\/code> exists from day one.<\/strong> You will rotate the encryption key eventually. Adding a version column to a table full of ciphertext you cannot distinguish is a migration nobody enjoys. Adding it up front costs two bytes per row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>last_refresh_error<\/code> is a text column, deliberately.<\/strong> When a refresh fails you want the provider&#8217;s <code>error_description<\/code> verbatim, in the row, visible to support without a log search. Truncate it to something sane before writing, and never write the token into it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"encryption-at-rest\" class=\"wp-block-heading\">Encryption at Rest, and What That Phrase Has to Mean<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;Encrypted at rest&#8221; is often satisfied by turning on full disk encryption and moving on. For a token store that is not sufficient, and the reason is specific: the realistic threat is not somebody stealing the physical disk, it is a SQL injection, a leaked read replica credential, an over-permissioned analytics connection, or a database dump landing in a backup bucket. Disk encryption defends against none of those, because in all of them the attacker reads through the database, which decrypts transparently.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The property you actually need is that the ciphertext in the <code>access_token_enc<\/code> column is useless to somebody holding a full dump of the database. That requires the key to live somewhere the database does not, which in practice means a key management service, or at minimum an environment variable on the application host that is not in the same blast radius as the database credentials.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use an authenticated encryption mode so that tampering is detectable rather than silently producing garbage that you then send to the provider as a bearer token. AES-GCM or a library that wraps it, such as <code>cryptography<\/code>&#8216;s Fernet in Python or Node&#8217;s built in <code>crypto<\/code> with <code>aes-256-gcm<\/code>, is the ordinary choice. Bind the tenant identifier in as additional authenticated data, so that a row copied from one tenant to another fails to decrypt rather than working:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from cryptography.hazmat.primitives.ciphers.aead import AESGCM\nimport os\n\ndef seal(plaintext: str, key: bytes, tenant_id: int) -&gt; bytes:\n    \"\"\"Encrypt a token, binding it to the tenant it belongs to.\n\n    Passing tenant_id as associated data means a ciphertext moved between\n    rows fails to decrypt. That converts a whole class of authorization bug\n    into a loud, immediate error instead of a silent cross-tenant send.\n    \"\"\"\n    nonce = os.urandom(12)\n    aad = str(tenant_id).encode(\"ascii\")\n    ct = AESGCM(key).encrypt(nonce, plaintext.encode(\"utf-8\"), aad)\n    return nonce + ct<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The associated data trick is the part worth copying. Cross tenant token confusion is the worst bug this system can have, because its symptom is one customer&#8217;s messages going out through another customer&#8217;s account and being billed to them. Making it cryptographically impossible rather than merely unlikely is cheap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, the negative rules, which matter as much as the positive ones. Never log a token, not even a prefix. Never put a token in a URL query string, where it lands in access logs and referrer headers. Never return a token to your own front end, even to an admin screen. Never include one in an error report, a Sentry breadcrumb, or a support ticket attachment. If you need to correlate logs across a token&#8217;s lifetime, log the connection row id, which is meaningless to anyone who does not already have your database.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"making-authenticated-calls\" class=\"wp-block-heading\">Making Authenticated Calls: Token Plus Identity<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Putting the pieces together, here is the shape every outbound call takes. This example uses the standard <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">Send SMS endpoint<\/a>, and everything documented there about parameters, batching and India DLT applies unchanged. OAuth changes how you authenticate, not what you send.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import httpx\n\nBASE = \"https:\/\/unify.smsgateway.center\"\n\ndef send_sms(conn, mobile: str, msg: str, senderid: str) -&gt; dict:\n    \"\"\"Send on behalf of a connected account.\n\n    Two credentials, two jobs: the bearer token authorizes the call, the\n    userid parameter says whose account to send from. Both are required.\n    \"\"\"\n    access_token, username = conn.access_token, conn.provider_username\n\n    # httpx defaults to a 5 second timeout. requests defaults to NO timeout.\n    # Set it explicitly either way so the value is visible in the code.\n    with httpx.Client(timeout=httpx.Timeout(10.0, connect=5.0)) as client:\n        r = client.post(\n            f\"{BASE}\/SMSApi\/send\",\n            headers={\"Authorization\": f\"Bearer {access_token}\"},\n            data={\n                \"userid\": username,          # NOT optional under OAuth\n                \"sendMethod\": \"quick\",\n                \"msgType\": \"text\",\n                \"mobile\": mobile,\n                \"msg\": msg,\n                \"senderid\": senderid,\n                \"output\": \"json\",\n            },\n        )\n    r.raise_for_status()\n    return r.json()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things to hold on to. Send form encoded data, not JSON: posting a JSON body to the send endpoint presents as an authentication error rather than as a content type error, which sends people down entirely the wrong debugging path. And note that the response shape from a send has been documented with a <code>msgId<\/code> field present in one sample and absent in another, so parse defensively and do not assume a fixed set of keys. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/\">Python integration tutorial<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js tutorial<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">PHP tutorial<\/a> all cover response normalisation in depth, and all of that work is reusable here without change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation also states that the access token may be sent as the <code>apikey<\/code> header rather than as <code>Authorization: Bearer<\/code>. Pick one and use it consistently. The bearer header is the conventional choice and travels better through proxies and API gateways that already understand it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-error-table\" class=\"wp-block-heading\">The Error Table and What <code>invalid_grant<\/code> Actually Means<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Errors come back shaped like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\"error\": \"invalid_grant\", \"error_description\": \"...\"}<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Error code<\/th><th>Usual real cause<\/th><th>Correct response<\/th><\/tr><\/thead><tbody><tr><td><code>invalid_request<\/code><\/td><td>A required parameter is missing or duplicated, or the body is not form encoded<\/td><td>Fix the request. Never retry unchanged.<\/td><\/tr><tr><td><code>invalid_client<\/code><\/td><td>Wrong <code>client_id<\/code> or <code>client_secret<\/code>, or the client is not registered for this environment<\/td><td>Configuration error. Alert your own team, not the customer.<\/td><\/tr><tr><td><code>invalid_grant<\/code><\/td><td>See below. Several distinct causes share this code.<\/td><td>Depends entirely on which cause.<\/td><\/tr><tr><td><code>unsupported_response_type<\/code><\/td><td><code>response_type<\/code> is not <code>code<\/code><\/td><td>Fix the authorize URL.<\/td><\/tr><tr><td><code>unsupported_grant_type<\/code><\/td><td><code>grant_type<\/code> is not <code>authorization_code<\/code> or <code>refresh_token<\/code><\/td><td>Fix the token request.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><code>invalid_grant<\/code> deserves its own treatment because it is the one you will actually see, and it is overloaded. In the authorization code exchange it can mean the code has expired past its ten minutes, the code has already been used, the <code>redirect_uri<\/code> does not exactly match, or the <code>code_verifier<\/code> does not hash to the <code>code_challenge<\/code>. In a refresh it can mean the refresh token has passed its 30 days, or the grant was revoked, either by you calling revoke or by the customer disconnecting from their side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reason to care about the distinction is that the correct user facing behaviour is opposite in the two cases. During a code exchange, <code>invalid_grant<\/code> almost always means a bug in your own flow, and the customer should see &#8220;something went wrong, try again&#8221; while you get an alert. During a refresh, it almost always means the grant is genuinely gone, and the customer should see &#8220;your messaging account was disconnected, reconnect it here&#8221; with no alert to you at all. Getting these backwards produces either an alert storm from customers legitimately disconnecting, or silence while your PKCE implementation is broken for everyone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Since the two cases are distinguished by which grant type you sent, not by anything in the response, the classification has to happen in your code:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class ReauthRequired(Exception):\n    \"\"\"The customer must reconnect. Not our bug, not retryable.\"\"\"\n\nclass OAuthConfigError(Exception):\n    \"\"\"Our bug. Page someone.\"\"\"\n\ndef classify(grant_type: str, error: str) -&gt; Exception:\n    if error == \"invalid_grant\":\n        return ReauthRequired() if grant_type == \"refresh_token\" else OAuthConfigError()\n    if error in (\"invalid_client\", \"unsupported_grant_type\", \"unsupported_response_type\"):\n        return OAuthConfigError()\n    return OAuthConfigError()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note that the default branch is the loud one. When an unrecognised error code arrives, treating it as your bug gets it looked at. Treating it as the customer&#8217;s problem makes it invisible.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"revocation-on-disconnect\" class=\"wp-block-heading\">Revocation on Disconnect<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When a customer clicks Disconnect in your product, three things must happen, in this order:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST 'https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/revoke' \\\n  -H 'Content-Type: application\/x-www-form-urlencoded' \\\n  --data-urlencode 'token=THE_ACCESS_OR_REFRESH_TOKEN'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Success returns <code>{\"revoked\": true}<\/code>.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Call revoke.<\/strong> Ending your side of the relationship without telling the provider leaves a live grant that you are no longer tracking. That is exactly the kind of orphan that turns up in a security review.<\/li>\n\n\n\n<li><strong>Delete or null the ciphertext columns, and set <code>status = 'revoked'<\/code> and <code>disconnected_at<\/code>.<\/strong> Keep the row. You want the audit trail of when a connection existed and when it ended, and you want reconnection to update this row rather than create a duplicate.<\/li>\n\n\n\n<li><strong>Stop the sweep from picking it up.<\/strong> The partial index above already handles this, because the sweep query filters on <code>status = 'active'<\/code>. This is a concrete payoff for making status explicit rather than inferred.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Order matters in one specific way. If revoke fails with a transport error, do not skip the local cleanup. The customer asked to disconnect and your product must honour that immediately. Record the failed revocation and retry it from a background job, but the local state changes now.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"revoke-versus-logout\" class=\"wp-block-heading\">Revoke and Logout Are Not the Same Endpoint<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Worth stating plainly because both exist and both invalidate a token.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>POST https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/revoke<\/code> takes a <code>token<\/code> parameter and ends an OAuth grant.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>POST https:\/\/unify.smsgateway.center\/SMSApi\/logout<\/code> takes an <code>Authorization: Bearer<\/code> header and a JSON body containing <code>userId<\/code>, and invalidates a session token issued by the separate Basic Auth mechanism at <code>\/me\/<\/code>. It returns:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"success\",\n  \"statusCode\": \"200\",\n  \"reason\": \"Logout successful\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two token systems, two invalidation endpoints, two different request shapes, and even two different spellings of the identity field, <code>userId<\/code> in the logout body versus <code>userid<\/code> in the send parameters. If your codebase uses both mechanisms, keep them in separate modules with names that make the distinction impossible to miss. Sending an OAuth access token to the logout endpoint, or a Basic Auth session token to the revoke endpoint, will not do what you want, and the failure will not be self explanatory.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"three-integration-archetypes\" class=\"wp-block-heading\">The Three Integration Archetypes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation names three kinds of integration this API is built for, and each has a different centre of gravity.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Automation Platforms<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Zapier, Make and similar. Here OAuth is effectively mandatory, because those platforms have a standard connector model built around it and their review processes expect it. The specific engineering constraint is that the platform, not you, owns the token refresh loop in some connector models and you own it in others. Read the target platform&#8217;s connector specification before you build, because retrofitting a refresh loop into a connector that assumed the platform owned it is a rewrite.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">AI Connectors<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">ChatGPT and similar assistant integrations. The distinguishing feature is that the calling pattern is unpredictable and bursty: a user asks the assistant to send a message and the call arrives with no warning, possibly after weeks of inactivity. This is the archetype where the dormant-connection problem from the refresh section bites hardest, because there is no daily batch keeping the token warm. Scheduled proactive refresh is not optional here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a second consideration specific to this archetype. An assistant can be persuaded to send a message by content it read, which makes the boundary between &#8220;the user asked for this&#8221; and &#8220;something the model read asked for this&#8221; the security question that matters. Confirmation before send, and a hard per-connection rate limit, belong in your integration regardless of what the model layer does.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Custom Web and Mobile Apps<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Your own product, sending on behalf of your customers. The largest archetype and the one this article is written around. The distinguishing constraint is multi-tenancy, which is why so much of this article is about the schema rather than about the flow.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One point specific to mobile: the OAuth flow must run in a system browser or an in-app browser tab, never in an embedded webview that your app controls. An embedded webview can read the customer&#8217;s credentials as they type them, which defeats the entire purpose of delegating authentication, and it is the reason mobile OAuth guidance has converged on system browsers. The token exchange itself, with the client secret, belongs on your server, not in the app binary.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reference-implementation-nodejs\" class=\"wp-block-heading\">Reference Implementation: Node.js<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A complete connect and refresh path. Error handling is deliberately explicit rather than swallowed.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import crypto from \"node:crypto\";\n\nconst BASE = \"https:\/\/unify.smsgateway.center\";\nconst CLIENT_ID = process.env.SGC_CLIENT_ID;\nconst CLIENT_SECRET = process.env.SGC_CLIENT_SECRET;\nconst REDIRECT_URI = process.env.SGC_REDIRECT_URI;\n\nconst b64url = (buf) =&gt; buf.toString(\"base64url\");\n\nexport function makePkcePair() {\n  const verifier = b64url(crypto.randomBytes(32));\n  \/\/ Hash the ASCII characters of the ENCODED verifier, not the raw bytes.\n  const challenge = b64url(crypto.createHash(\"sha256\").update(verifier, \"ascii\").digest());\n  return { verifier, challenge };\n}\n\nexport function buildAuthorizeUrl({ challenge, state }) {\n  const u = new URL(`${BASE}\/rest\/oauth\/v1\/authorize`);\n  u.searchParams.set(\"response_type\", \"code\");\n  u.searchParams.set(\"client_id\", CLIENT_ID);\n  u.searchParams.set(\"redirect_uri\", REDIRECT_URI);\n  u.searchParams.set(\"state\", state);\n  u.searchParams.set(\"code_challenge\", challenge);\n  u.searchParams.set(\"code_challenge_method\", \"S256\");\n  return u.toString();\n}\n\nasync function postToken(params) {\n  \/\/ fetch has NO default timeout in Node. Without this, a hung connection\n  \/\/ holds the request open indefinitely.\n  const res = await fetch(`${BASE}\/rest\/oauth\/v1\/token`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application\/x-www-form-urlencoded\" },\n    body: new URLSearchParams(params),\n    signal: AbortSignal.timeout(15_000),\n  });\n\n  const body = await res.json();\n  if (!res.ok || body.error) {\n    const err = new Error(body.error_description || body.error || `HTTP ${res.status}`);\n    err.oauthCode = body.error;\n    throw err;\n  }\n  if (body.token_type !== \"Bearer\") {\n    throw new Error(`Unexpected token_type: ${body.token_type}`);\n  }\n  return body;\n}\n\nexport async function exchangeCode({ code, verifier }) {\n  const body = await postToken({\n    grant_type: \"authorization_code\",\n    client_id: CLIENT_ID,\n    client_secret: CLIENT_SECRET,\n    code,\n    redirect_uri: REDIRECT_URI,   \/\/ same exact string as in step 1\n    code_verifier: verifier,\n  });\n\n  return {\n    accessToken: body.access_token,\n    refreshToken: body.refresh_token,\n    \/\/ The username is the userid you must send on every SMS API call.\n    username: body.username,\n    accessExpiresAt: new Date(Date.now() + Number(body.expires_in) * 1000),\n    refreshExpiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000),\n  };\n}\n\nexport async function refreshTokens(stored) {\n  const body = await postToken({\n    grant_type: \"refresh_token\",\n    client_id: CLIENT_ID,\n    client_secret: CLIENT_SECRET,\n    refresh_token: stored.refreshToken,\n  });\n\n  const returned = (body.refresh_token || \"\").trim();\n\n  return {\n    accessToken: body.access_token,\n    \/\/ CRITICAL: on a refresh grant this comes back empty and the ORIGINAL\n    \/\/ refresh token stays valid. Overwriting it disconnects the customer\n    \/\/ one hour from now.\n    refreshToken: returned || stored.refreshToken,\n    username: body.username || stored.username,\n    accessExpiresAt: new Date(Date.now() + Number(body.expires_in) * 1000),\n    refreshExpiresAt: stored.refreshExpiresAt,\n  };\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>refreshExpiresAt: stored.refreshExpiresAt<\/code> in the refresh path. Since the refresh token is not rotated, its expiry is not extended by using it, so carrying the original value forward is the conservative reading. If it turns out the provider does extend it, the cost of this choice is one unnecessary reconnection prompt. The cost of assuming the opposite is a connection you believe is healthy and is not.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reference-implementation-python\" class=\"wp-block-heading\">Reference Implementation: Python<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The same flow, with the storage boundary made explicit.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from dataclasses import dataclass\nfrom datetime import datetime, timedelta, timezone\nimport httpx\n\nBASE = \"https:\/\/unify.smsgateway.center\"\n\n\n@dataclass(frozen=True)\nclass TokenSet:\n    access_token: str\n    refresh_token: str\n    username: str\n    access_expires_at: datetime\n    refresh_expires_at: datetime\n\n    def __repr__(self) -&gt; str:\n        # Redaction at the type level. A dataclass with a default repr will\n        # print your tokens into any log line, traceback or debugger session\n        # that touches it. This is the cheapest place to stop that.\n        return (\n            f\"TokenSet(username={self.username!r}, \"\n            f\"access_expires_at={self.access_expires_at.isoformat()}, \"\n            \"access_token=***, refresh_token=***)\"\n        )\n\n    def needs_refresh(self, *, now: datetime, margin_seconds: int = 600) -&gt; bool:\n        return self.access_expires_at - now &lt;= timedelta(seconds=margin_seconds)\n\n\nclass OAuthError(Exception):\n    def __init__(self, code: str, description: str):\n        super().__init__(f\"{code}: {description}\")\n        self.code = code\n\n\ndef _post_token(params: dict) -&gt; dict:\n    # requests has NO default timeout. httpx defaults to 5 seconds.\n    # Either way, set it explicitly so the value is reviewable.\n    with httpx.Client(timeout=httpx.Timeout(15.0, connect=5.0)) as client:\n        r = client.post(f\"{BASE}\/rest\/oauth\/v1\/token\", data=params)\n\n    body = r.json()\n    if \"error\" in body:\n        raise OAuthError(body&#91;\"error\"], body.get(\"error_description\", \"\"))\n    r.raise_for_status()\n    if body.get(\"token_type\") != \"Bearer\":\n        raise OAuthError(\"invalid_response\", f\"token_type={body.get('token_type')!r}\")\n    return body\n\n\ndef exchange_code(*, code: str, verifier: str, client_id: str,\n                  client_secret: str, redirect_uri: str,\n                  now: datetime | None = None) -&gt; TokenSet:\n    now = now or datetime.now(timezone.utc)\n    body = _post_token({\n        \"grant_type\": \"authorization_code\",\n        \"client_id\": client_id,\n        \"client_secret\": client_secret,\n        \"code\": code,\n        \"redirect_uri\": redirect_uri,\n        \"code_verifier\": verifier,\n    })\n    return TokenSet(\n        access_token=body&#91;\"access_token\"],\n        refresh_token=body&#91;\"refresh_token\"],\n        username=body&#91;\"username\"],\n        access_expires_at=now + timedelta(seconds=int(body&#91;\"expires_in\"])),\n        refresh_expires_at=now + timedelta(days=30),\n    )\n\n\ndef refresh(stored: TokenSet, *, client_id: str, client_secret: str,\n            now: datetime | None = None) -&gt; TokenSet:\n    now = now or datetime.now(timezone.utc)\n    body = _post_token({\n        \"grant_type\": \"refresh_token\",\n        \"client_id\": client_id,\n        \"client_secret\": client_secret,\n        \"refresh_token\": stored.refresh_token,\n    })\n    returned = (body.get(\"refresh_token\") or \"\").strip()\n    return TokenSet(\n        access_token=body&#91;\"access_token\"],\n        refresh_token=returned or stored.refresh_token,\n        username=body.get(\"username\") or stored.username,\n        access_expires_at=now + timedelta(seconds=int(body&#91;\"expires_in\"])),\n        refresh_expires_at=stored.refresh_expires_at,\n    )<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>__repr__<\/code> override is the Python specific move worth stealing. A frozen dataclass holding secrets prints them by default, and it prints them in exactly the situations where you are least paying attention: an unhandled exception, a debugger repl, a structured log that serialises the whole object. Overriding <code>__repr__<\/code> once at the type level protects every call site, including the ones written by people who never read this article. The equivalent in Node is a <code>toJSON<\/code> on the class, and in Java it is an overridden <code>toString<\/code> on the record.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the Spring Security wiring of this same flow, including <code>OAuth2AuthorizationRequestCustomizers.withPkce()<\/code>, the Java and Spring Boot integration guide covers it, and PHP applications can follow the same shapes using the patterns in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">PHP integration tutorial<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"testing-an-oauth-integration\" class=\"wp-block-heading\">Testing an OAuth Integration Without Sending Messages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The good news is that most of this is testable for free. The OAuth endpoints do not send messages and do not bill, so unlike testing the send path, you are not choosing between coverage and cost.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What to cover, in rough order of value:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Test<\/th><th>Why it earns its place<\/th><\/tr><\/thead><tbody><tr><td>PKCE pair round trip against a known vector<\/td><td>Catches base64 versus base64url and raw-bytes-versus-ascii, the two silent PKCE bugs<\/td><\/tr><tr><td>Refresh response with <code>\"refresh_token\": \"\"<\/code><\/td><td>The single most expensive bug in this article. Assert the stored value is unchanged.<\/td><\/tr><tr><td>Refresh response with <code>refresh_token<\/code> key absent entirely<\/td><td>Same failure, different shape. Your merge logic must handle both.<\/td><\/tr><tr><td><code>state<\/code> mismatch on callback<\/td><td>Assert the flow aborts and no row is written<\/td><\/tr><tr><td><code>state<\/code> replay, same value twice<\/td><td>Assert the second attempt is rejected<\/td><\/tr><tr><td>Token response missing <code>username<\/code><\/td><td>Assert you fail loudly rather than storing a row that cannot send<\/td><\/tr><tr><td><code>token_type<\/code> not equal to <code>Bearer<\/code><\/td><td>Assert you reject rather than constructing a broken header<\/td><\/tr><tr><td>Expiry computed from response arrival with a fixed clock<\/td><td>Assert <code>needs_refresh<\/code> flips at exactly the margin boundary<\/td><\/tr><tr><td>Cross tenant decrypt attempt<\/td><td>Assert the AAD binding makes it fail rather than succeed<\/td><\/tr><tr><td><code>invalid_grant<\/code> on refresh versus on code exchange<\/td><td>Assert the two classify differently, since their remediation is opposite<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Mock at the transport layer, not at your own client class. Mocking your own client and asserting it was called proves that your code calls your code. Use <code>respx<\/code> in Python or <code>nock<\/code> in Node so that the thing under test is the actual request you would put on the wire, including the content type and the exact parameter names. The general argument for this, along with the layering that goes with it, applies to every integration on this API and not only to OAuth.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One live test is worth running manually before launch, in an environment where you accept the cost: complete a real authorization, wait for the access token to actually expire, and confirm your refresh path recovers without human intervention. An hour is a long time in a test suite and a short time in a release cycle. This is the single test that catches the empty refresh token bug end to end, and it is the one that a mocked suite can pass while production fails.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"observability-for-the-connection\" class=\"wp-block-heading\">Observability for the Connection Itself<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A connected account is a piece of infrastructure and deserves the same treatment as a queue or a database pool. Four signals, in descending order of usefulness:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Count of connections by status.<\/strong> A gauge, labelled by <code>status<\/code>, refreshed on a schedule. A rising <code>needs_reauth<\/code> count is the earliest and clearest signal that something systemic broke, for instance your client secret rotated or your refresh job stopped. Alert on the ratio of <code>needs_reauth<\/code> to <code>active<\/code>, not on the absolute count, because the absolute count grows with your customer base and any threshold you pick becomes wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Age of the oldest connection due for refresh.<\/strong> The single most predictive number, for the same reason oldest item age beats queue depth as a queue health signal: it encodes both &#8220;how far behind is the sweep&#8221; and &#8220;has the sweep stopped entirely&#8221; in one value. If the oldest due connection is thirty minutes overdue, your refresh job is not running, and you know that before any customer sees a failure. Return zero rather than null when nothing is due, so a stalled job does not produce an empty result that your alerting reads as healthy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Refresh outcomes as a counter.<\/strong> Labelled by result, which should be a small closed set such as success, <code>invalid_grant<\/code>, and transport error. Do not label by <code>error_description<\/code>, which is free text and will blow up your metric cardinality.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Time to first successful send after connect.<\/strong> A histogram. This is a product metric more than an operational one, and it is the one that tells you whether your connect flow actually works for real customers rather than for you. A long tail here usually means people are completing the OAuth flow and then getting stuck on something else, most often a sender ID or DLT template that is not set up yet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What must never become a metric label: the token, the username, the tenant identifier, or the customer&#8217;s mobile numbers. The first is a secret, the second and third are unbounded cardinality, and the fourth is personal data. If you need per tenant visibility, that is a query against your own database, not a time series label.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-step-build-order\" class=\"wp-block-heading\">Ten Step Build Order<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Request client registration<\/strong> through the <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">contact page<\/a>. Ask for both your production and your development redirect URIs in the same request, since exact matching means they are separate registrations.<\/li>\n\n\n\n<li><strong>Build the token store table<\/strong> before writing any HTTP code. The schema forces the questions that the flow lets you postpone.<\/li>\n\n\n\n<li><strong>Implement PKCE generation and verify it<\/strong> against a known test vector. Do this before anything touches the network, because a wrong challenge fails several steps later with a misleading error.<\/li>\n\n\n\n<li><strong>Implement the authorize redirect<\/strong> with server side <code>state<\/code> and verifier storage keyed to both the user session and the tenant.<\/li>\n\n\n\n<li><strong>Implement the callback<\/strong>, checking <code>state<\/code> with a constant time comparison and consuming it so it cannot be replayed.<\/li>\n\n\n\n<li><strong>Implement the code exchange<\/strong> and persist all five fields: both tokens, the username, and both absolute expiry instants.<\/li>\n\n\n\n<li><strong>Implement refresh with the merge logic<\/strong> that never lets an empty response value overwrite a stored refresh token. Write the test named after the bug.<\/li>\n\n\n\n<li><strong>Implement the scheduled sweep<\/strong> that refreshes connections approaching expiry, and the gauge for oldest overdue connection.<\/li>\n\n\n\n<li><strong>Implement disconnect<\/strong>: revoke, then clear ciphertext, then set status, in that order, and make the local state change unconditional on the revoke succeeding.<\/li>\n\n\n\n<li><strong>Implement the customer facing reauth prompt<\/strong>, driven by <code>status = 'needs_reauth'<\/code>, and only then wire the send path.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Sending is step ten deliberately. Every integration that builds the send path first ends up with token management bolted on afterwards, and token management is the part that determines whether the integration still works in six weeks.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision Matrix<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Situation<\/th><th>Use<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>Your own backend, your own account<\/td><td><code>apiKey<\/code> header<\/td><td>Simplest thing that works. No delegation involved.<\/td><\/tr><tr><td>Your own backend, you want expiring credentials<\/td><td>Basic Auth to <code>\/me\/<\/code><\/td><td>Session tokens expire. You legitimately hold your own password.<\/td><\/tr><tr><td>SaaS product sending for many customers<\/td><td><strong>OAuth<\/strong><\/td><td>The only mechanism where the customer can revoke without rotating a password.<\/td><\/tr><tr><td>Zapier or Make style connector<\/td><td><strong>OAuth<\/strong><\/td><td>Expected by the platform&#8217;s connector model.<\/td><\/tr><tr><td>AI assistant connector<\/td><td><strong>OAuth<\/strong>, plus scheduled refresh<\/td><td>Bursty and dormant calling patterns make proactive refresh mandatory.<\/td><\/tr><tr><td>Mobile app sending from the user&#8217;s own account<\/td><td><strong>OAuth<\/strong>, system browser, exchange on your server<\/td><td>The client secret must not ship in the binary.<\/td><\/tr><tr><td>Internal cron job, single account<\/td><td><code>apiKey<\/code> header<\/td><td>OAuth adds a refresh loop for no benefit.<\/td><\/tr><tr><td>Agency managing several client accounts<\/td><td><strong>OAuth<\/strong>, one row per client<\/td><td>Unique on the tenant and username pair, one connection each.<\/td><\/tr><tr><td>Reseller provisioning sub accounts<\/td><td>Reseller API with your own credentials<\/td><td>A different problem. These are your accounts, not delegated ones.<\/td><\/tr><tr><td>Short lived script or one off migration<\/td><td><code>apiKey<\/code> header<\/td><td>Do not build a token store for something that runs once.<\/td><\/tr><tr><td>You need to prove to a security reviewer that you never hold customer passwords<\/td><td><strong>OAuth<\/strong><\/td><td>This is the answer the question is asking for.<\/td><\/tr><tr><td>Customer refuses to use a browser based flow<\/td><td>Reconsider the requirement<\/td><td>Every alternative involves you holding their password.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"pre-launch-checklist\" class=\"wp-block-heading\">Pre Launch Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Registration and configuration<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Client registered, with production and development redirect URIs both requested<\/li>\n\n\n\n<li>Client secret in a secrets manager, not in source control, not in an image layer<\/li>\n\n\n\n<li>Redirect URI in code matches the registration byte for byte, including trailing slash<\/li>\n\n\n\n<li>Same redirect URI string sent in both the authorize request and the token request<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>PKCE and CSRF<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Verifier is at least 43 characters, generated with a cryptographic random source<\/li>\n\n\n\n<li>Encoding is base64url with padding stripped, in both verifier and challenge<\/li>\n\n\n\n<li>SHA-256 computed over the ASCII of the encoded verifier<\/li>\n\n\n\n<li><code>code_challenge_method<\/code> set to <code>S256<\/code><\/li>\n\n\n\n<li>Verifier stored server side only, never sent to the browser<\/li>\n\n\n\n<li><code>state<\/code> is high entropy, single use, and bound to both session and tenant<\/li>\n\n\n\n<li><code>state<\/code> compared with a constant time comparison<\/li>\n\n\n\n<li><code>state<\/code> deleted on use so it cannot be replayed<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Token handling<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>username<\/code> persisted from the token response<\/li>\n\n\n\n<li><code>userid<\/code> sent on every SMS API call alongside the bearer token<\/li>\n\n\n\n<li><code>token_type<\/code> asserted to equal <code>Bearer<\/code><\/li>\n\n\n\n<li><code>expires_in<\/code> converted to an absolute UTC instant at receipt<\/li>\n\n\n\n<li>Refresh token expiry stored separately, at 30 days<\/li>\n\n\n\n<li><strong>Empty or absent <code>refresh_token<\/code> in a refresh response does not overwrite the stored one<\/strong><\/li>\n\n\n\n<li>A test exists specifically for the line above<\/li>\n\n\n\n<li>Tokens never logged, never in a URL, never returned to a front end<\/li>\n\n\n\n<li><code>scope<\/code> not used for any authorization decision<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Storage<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Tokens encrypted with an authenticated mode, key held outside the database<\/li>\n\n\n\n<li>Tenant identifier bound in as additional authenticated data<\/li>\n\n\n\n<li><code>key_version<\/code> column present from the first migration<\/li>\n\n\n\n<li>Unique constraint on the tenant and provider username pair<\/li>\n\n\n\n<li>Reconnect is an upsert against that constraint, not an insert<\/li>\n\n\n\n<li><code>status<\/code> is an explicit column with a check constraint<\/li>\n\n\n\n<li>Partial index supporting the refresh sweep<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Operations<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Scheduled proactive refresh running, with a margin larger than your longest request<\/li>\n\n\n\n<li>Gauge for oldest overdue connection, returning zero not null when none are due<\/li>\n\n\n\n<li>Refresh outcome counter with bounded label cardinality<\/li>\n\n\n\n<li><code>invalid_grant<\/code> on refresh classified as reauth, on code exchange as your bug<\/li>\n\n\n\n<li>Customer facing reauth banner driven by <code>status<\/code><\/li>\n\n\n\n<li>Disconnect calls revoke, clears ciphertext, and sets status, in that order<\/li>\n\n\n\n<li>Failed revocations queued for retry without blocking local cleanup<\/li>\n\n\n\n<li>Introspection available to support tooling and absent from the send path<\/li>\n\n\n\n<li>One manual end to end test completed where a real token was allowed to expire<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes\" class=\"wp-block-heading\">Ten Mistakes That Cost Real Money<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Overwriting the refresh token with the empty string returned by a refresh.<\/strong> Every connected customer breaks one hour after their first refresh, staggered by connection date so it looks environmental.<\/li>\n\n\n\n<li><strong>Not persisting <code>username<\/code>.<\/strong> Authentication succeeds, every send fails, and the error does not point at the cause.<\/li>\n\n\n\n<li><strong>Retrying a send after an authentication failure mid batch.<\/strong> The first attempt may have been accepted. You have now billed the customer twice for a large batch.<\/li>\n\n\n\n<li><strong>Hashing the raw random bytes instead of the encoded verifier in PKCE.<\/strong> Fails at the token endpoint with a generic invalid grant, several steps from the actual mistake.<\/li>\n\n\n\n<li><strong>Treating <code>invalid_grant<\/code> on refresh as an alertable incident.<\/strong> Every legitimate customer disconnection pages someone, so the alert gets muted, so the real failure is missed.<\/li>\n\n\n\n<li><strong>Storing <code>expires_in<\/code> instead of an absolute instant.<\/strong> The value is already wrong by the time it is written and every consumer has to guess when it was received.<\/li>\n\n\n\n<li><strong>Putting the tenant identifier in the redirect URI.<\/strong> Exact matching rejects it, and the fix under deadline pressure is usually to weaken the state handling instead.<\/li>\n\n\n\n<li><strong>Calling introspect before every send.<\/strong> Doubles request volume, adds latency, tells you nothing your database did not already know.<\/li>\n\n\n\n<li><strong>Relying on the <code>scope<\/code> string for permissions.<\/strong> It is undocumented and empty. Behaviour built on it breaks the day it is populated.<\/li>\n\n\n\n<li><strong>Running the mobile flow in an embedded webview.<\/strong> Your app can read the credentials, which defeats delegation entirely and will fail a security review.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-this-article-does-not-claim\" class=\"wp-block-heading\">What This Article Deliberately Does Not Claim<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ten things the documentation does not establish, stated plainly rather than guessed at. If you need any of these, ask before you build on an assumption.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Which scope values are supported.<\/strong> The documentation describes <code>scope<\/code> only as an optional string &#8220;if configured for your client&#8221; and the sample shows it empty. No scope vocabulary is published.<\/li>\n\n\n\n<li><strong>Whether the 30 day refresh window runs from issuance or from last use.<\/strong> This article assumes issuance, which is the conservative reading, and says so where it matters.<\/li>\n\n\n\n<li><strong>Whether refresh token rotation might be introduced later.<\/strong> The merge logic recommended here is correct under both current and rotating behaviour, which is part of why it is recommended.<\/li>\n\n\n\n<li><strong>The timezone of the <code>expires_after<\/code> value<\/strong> returned by the separate Basic Auth mechanism. It is a bare datetime string with no offset.<\/li>\n\n\n\n<li><strong>Any rate limit on the OAuth endpoints.<\/strong> No figures are published for authorize, token, revoke or introspect.<\/li>\n\n\n\n<li><strong>The internal structure of the access token.<\/strong> The sample labels it as an API key value. Nothing establishes that it is parseable, and this article recommends never parsing it.<\/li>\n\n\n\n<li><strong>Whether an access token issued through OAuth is the same value as the account&#8217;s API key<\/strong>, or merely resembles one in the sample. Do not build anything that depends on either answer.<\/li>\n\n\n\n<li><strong>How many simultaneous grants one account may hold.<\/strong> Nothing published establishes whether connecting a second application invalidates the first.<\/li>\n\n\n\n<li><strong>Whether revoking an access token also revokes the associated refresh token, or only the token presented.<\/strong> This article recommends revoking and then clearing local state regardless, which is correct either way.<\/li>\n\n\n\n<li><strong>Any service level commitment on the authorization page availability.<\/strong> It is a browser facing sign in page and your connect flow depends on it, which is worth knowing when you design the failure message.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Two documentation defects noticed while researching this article, worth flagging to the docs team rather than working around silently: several sample request blocks across the developer documentation include live session cookies such as <code>PHPSESSID<\/code>, <code>SERVERID<\/code> and <code>SERVERNAME<\/code>, almost certainly a copy and paste artefact from a browser session, which readers should not replicate. And the identity field is spelled <code>userId<\/code> in the logout request body and <code>userid<\/code> in the send request parameters, which is the kind of inconsistency that costs somebody an afternoon.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need OAuth if I am only sending from my own account?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. Use an <code>apiKey<\/code> header. OAuth adds a refresh loop, a token store and a reauth flow, and delivers nothing in return when there is no third party account to delegate access to. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/create-use-sms-gateway-center-api-key\/\">API key guide<\/a> covers that path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I register an OAuth client?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Manually, through the <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">contact page<\/a>. There is no self service client registration endpoint. Request all of your redirect URIs in one go, since exact matching means development and production are separate registrations.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my send fail even though the token is valid?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Almost certainly because you are not sending <code>userid<\/code>. The access token does not carry account identity, and the documentation states that OAuth does not replace the SMS API request format. Send the <code>username<\/code> from the token response as the <code>userid<\/code> parameter on every request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The refresh response has an empty <code>refresh_token<\/code>. Is that a bug?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No, it is the documented behaviour. The original refresh token remains valid. Your code must not overwrite the stored value with the empty response value. This is the most expensive mistake in this integration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How long do the tokens last?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The authorization code is valid for 10 minutes, the access token for 3600 seconds, and the refresh token for 30 days.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I use PKCE if my client is confidential?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. The authorization code travels through the user&#8217;s browser regardless of whether you can keep a client secret, and PKCE is what binds that code to your server. The documented method is <code>S256<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I put my tenant ID in the redirect URI?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. Redirect URI matching is exact, so any added parameter breaks the match. Put the tenant identifier in the server side record you key by <code>state<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the difference between revoke and logout?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>POST \/rest\/oauth\/v1\/revoke<\/code> ends an OAuth grant and takes a <code>token<\/code> parameter. <code>POST \/SMSApi\/logout<\/code> invalidates a session token issued by the separate Basic Auth mechanism and takes a bearer header plus a <code>userId<\/code> body field. They are not interchangeable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I call introspect before every request?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. Your own stored expiry answers the same question without a network call. Use introspection for reconciliation after an incident, for recovering a missing <code>username<\/code>, and in support tooling.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I handle a customer who has not sent anything for a month?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Refresh on a schedule rather than only on demand, so dormant connections stay warm, and surface a clear reauth prompt when the grant has genuinely lapsed. This matters most for assistant style integrations where calls arrive unpredictably.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can one customer connect two messaging accounts?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, if your schema allows it. Make the unique constraint cover the tenant and provider username pair rather than the tenant alone, and make reconnection an upsert against that constraint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is two factor authentication supported during the connect flow?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. If OTP or Google Authenticator two factor is enabled on the account, it is honoured on the authorization page. Your application does not need to handle it and should not attempt to.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What should I log?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The connection row id, the grant type, the outcome, and the provider&#8217;s <code>error_description<\/code> on failure. Never the tokens, not even a prefix, and never in a URL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does OAuth change how I send DLT compliant messages in India?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. OAuth changes authentication only. <code>dltEntityId<\/code>, <code>dltTemplateId<\/code>, template matching and sender ID rules are unchanged, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-registration-step-by-step-guide-india\/\">DLT registration guide<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">DLT template rejection guide<\/a> apply exactly as they do to any other authentication method.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Building a product that sends on behalf of your customers?<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><br>Request an OAuth client registration through our <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">contact page<\/a>, and tell us your production and development redirect URIs in the same message. If you want to prototype the send path first, the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">demo environment<\/a> gives you an account to work against, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">full API reference<\/a> documents every endpoint your integration will touch.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>If your product sends SMS on behalf of your customers, stop asking them to paste an API key. This is a full engineering guide to the SMSGatewayCenter REST OAuth API: the authorization code flow with PKCE, token lifetimes, the refresh response that returns an empty refresh token, the multi-tenant token store, revocation on disconnect, and the one semantic that breaks most first integrations, which is that the access token does not carry account identity.<\/p>\n","protected":false},"author":118,"featured_media":2934,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[479,2204,1991,1080,2207,2205,2206,2209,2202,481,2208,2203],"class_list":["post-2933","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-api-key","tag-authentication","tag-developer-guide","tag-integration","tag-isv","tag-multi-tenant","tag-oauth","tag-partner-integrations","tag-pkce","tag-sms-api","tag-sms-gateway-api","tag-token-management"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2933","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=2933"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2933\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2934"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2933"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2933"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2933"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}