<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Francisco José Fernández</title>
    <description>The latest articles on DEV Community by Francisco José Fernández (@frandeveloper).</description>
    <link>https://dev.to/frandeveloper</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4127820%2F26838a7c-9d42-4a51-93bf-d3f3d6fbf88e.jpg</url>
      <title>DEV Community: Francisco José Fernández</title>
      <link>https://dev.to/frandeveloper</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/frandeveloper"/>
    <language>en</language>
    <item>
      <title>Meta access token expired? Why it lasts 60 days and how to refresh it</title>
      <dc:creator>Francisco José Fernández</dc:creator>
      <pubDate>Wed, 14 Oct 2026 12:05:30 +0000</pubDate>
      <link>https://dev.to/frandeveloper/meta-access-token-expired-why-it-lasts-60-days-and-how-to-refresh-it-2h81</link>
      <guid>https://dev.to/frandeveloper/meta-access-token-expired-why-it-lasts-60-days-and-how-to-refresh-it-2h81</guid>
      <description>&lt;p&gt;&lt;strong&gt;An Instagram access token issued by Meta lasts 60 days.&lt;/strong&gt; PlanVortex renews it automatically a day before it expires, so in normal use you never notice. You notice when that renewal fails: the account is flagged as down, stops retrying on its own, and your product gets a notification so it can ask the user to reconnect it.&lt;/p&gt;

&lt;p&gt;Not every Meta token works like that. A Facebook Page token has no expiry date, a system user token lasts as long as you choose when you create it, and WhatsApp has a token of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long does a Meta access token last?
&lt;/h2&gt;

&lt;p&gt;It depends on which token. These are the ones an integration meets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;How long it lasts&lt;/th&gt;
&lt;th&gt;How it is renewed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instagram user token (Instagram Login)&lt;/td&gt;
&lt;td&gt;60 days&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ig_refresh_token&lt;/code&gt;, once it is 24 hours old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facebook user token, long-lived&lt;/td&gt;
&lt;td&gt;About 60 days&lt;/td&gt;
&lt;td&gt;Through the login flow again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facebook Page token, from a long-lived user token&lt;/td&gt;
&lt;td&gt;No expiration date&lt;/td&gt;
&lt;td&gt;It does not need renewing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System user token (Business Manager)&lt;/td&gt;
&lt;td&gt;60 days or never, chosen at creation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fb_exchange_token&lt;/code&gt; with &lt;code&gt;set_token_expires_in_60_days=true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp token from the App Dashboard&lt;/td&gt;
&lt;td&gt;A few hours&lt;/td&gt;
&lt;td&gt;Generate a new one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For an Instagram account, 60 days is the window &lt;a href="https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token" rel="noopener noreferrer"&gt;Meta documents&lt;/a&gt; for the long-lived token you get when connecting it, and it is fixed: you cannot request a longer one and there is no paid plan that extends it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/orm/social_apis/instagram/index.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expiresDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;expiresDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiresDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSeconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 60 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;expiresDate&lt;/code&gt; is the only thing deciding whether an account needs renewing. There is no heuristic and no safety margin in that calculation: it is exactly 60 days from the instant Meta handed the token over, counted in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the Instagram token expire when a Page token does not?
&lt;/h2&gt;

&lt;p&gt;Because they stand for different things. The Instagram token is a user token: it represents the person who authorized the app, and Meta makes the integration come back every 60 days to show that person is still behind the permission. An expiry also limits the damage of a token that leaks (it stops working on its own, without anyone having to revoke it by hand).&lt;/p&gt;

&lt;p&gt;A Page token generated from a long-lived user token represents the Page, and &lt;a href="https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived" rel="noopener noreferrer"&gt;Meta says&lt;/a&gt; it has no expiration date and is only invalidated "under certain conditions". Meta's debugging guide lists what invalidates a token before its time: the person logging out of the app, changing their password or revoking the app's authorization. That is why PlanVortex renews Instagram accounts on a cycle and leaves Facebook Pages out of it.&lt;/p&gt;

&lt;p&gt;Either way, whoever integrates the API has to treat a dead token as a normal part of the account lifecycle, whether it died on a date or because something invalidated it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is a token renewed before it expires?
&lt;/h2&gt;

&lt;p&gt;With a job that runs once an hour and renews any account whose token is going to expire in less than 24 hours. The hourly cadence exists precisely so there is room to spare against that one-day window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/domain/repositories/account/index.ts: "Refrescamos con 1 dia de diferencia (requerido por instagram)"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;account_match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;error_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;access_data.expires_in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$lte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/jobs/index.ts&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refresh-tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;7 * * * *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// every hour, on minute 7&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refreshTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The renewal itself is a call to Meta's &lt;code&gt;refresh_access_token&lt;/code&gt; with &lt;code&gt;grant_type=ig_refresh_token&lt;/code&gt;, which returns a new token with another 60 days ahead of it. As long as that call succeeds, the cycle repeats on its own and the account never learns its token changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you get a Meta access token that never expires?
&lt;/h2&gt;

&lt;p&gt;There are two ways, and Meta is closing the second one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Page access token.&lt;/strong&gt; Exchange the short-lived user token from the login dialog for a long-lived one, then ask for the Page tokens with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /oauth/access_token?grant_type=fb_exchange_token&amp;amp;client_id={app-id}&amp;amp;client_secret={app-secret}&amp;amp;fb_exchange_token={short-lived-user-token}
GET /{user-id}/accounts?access_token={long-lived-user-token}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Page in the second response carries its own &lt;code&gt;access_token&lt;/code&gt;, and that one has no expiration date. Two conditions: the person asking must have a role on the Page, and the user token has to be the long-lived one, which is the input Meta asks for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A system user token.&lt;/strong&gt; In Business Manager (Business settings, then Users, then System users) you create a system user, give it the app and the assets, and generate a token. When you generate it you choose the expiry: never, or 60 days. Meta &lt;a href="https://developers.facebook.com/docs/business-management-apis/system-users/install-apps-and-generate-tokens" rel="noopener noreferrer"&gt;recommends the 60-day one&lt;/a&gt; as "a security best practice" and says that "all integrations should adopt expiring tokens". For businesses the requirement already applies to, asking for a non-expiring token returns an error. So the permanent system user token that many tutorials recommend may not be available to you.&lt;/p&gt;

&lt;p&gt;A 60-day system user token is refreshed with the same exchange call plus one parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /oauth/access_token?grant_type=fb_exchange_token&amp;amp;client_id={app-id}&amp;amp;client_secret={app-secret}&amp;amp;set_token_expires_in_60_days=true&amp;amp;fb_exchange_token={current-token}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response brings a new token and its &lt;code&gt;expires_in&lt;/code&gt;. Meta's advice is to refresh within the 60 days; once it lapses, you have to generate a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which token should you use for the WhatsApp Cloud API?
&lt;/h2&gt;

&lt;p&gt;The token on the API Setup screen is only good for testing. The App Dashboard gives you a user token that, &lt;a href="https://developers.facebook.com/docs/whatsapp/access-tokens" rel="noopener noreferrer"&gt;in Meta's words&lt;/a&gt;, expires quickly: you would have to generate a new one every few hours, which is enough to send a first test message.&lt;/p&gt;

&lt;p&gt;For anything after that, it depends on whose number it is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For your own business's number, a system user token, with everything said above about choosing 60 days or never.&lt;/li&gt;
&lt;li&gt;For your customers' numbers (Meta calls you a Tech Provider), a Business Integration System User access token, one per customer, which you get by implementing Embedded Signup and exchanging the code the flow returns. Meta presents it as the way to reach each customer's assets without asking them to authenticate again. That is how PlanVortex connects WhatsApp: the customer goes through Embedded Signup and the code is exchanged on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you check when a Meta token expires?
&lt;/h2&gt;

&lt;p&gt;Meta's &lt;code&gt;debug_token&lt;/code&gt; endpoint (the one behind the Access Token Debugger in its developer tools) returns, among other fields, &lt;code&gt;expires_at&lt;/code&gt; and &lt;code&gt;data_access_expires_at&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /debug_token?input_token={token-to-check}&amp;amp;access_token={app-id}|{app-secret}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They are two different dates. &lt;code&gt;expires_at&lt;/code&gt; is when the token stops working. &lt;code&gt;data_access_expires_at&lt;/code&gt; is the &lt;a href="https://developers.facebook.com/docs/facebook-login/auth-vs-data" rel="noopener noreferrer"&gt;data access window&lt;/a&gt;, 90 days from the person's last activity: past it, the person can still log in, but the app loses access to their data until they re-authorize the permissions. PlanVortex calls &lt;code&gt;debug_token&lt;/code&gt; when it connects a WhatsApp account, to read which assets the person granted and when the token expires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do tokens last the same on every network?
&lt;/h2&gt;

&lt;p&gt;No: each network forces its own deadline, and renewal code that treats them all alike ends up with dead accounts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Network&lt;/th&gt;
&lt;th&gt;access_token lifetime&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instagram (Meta)&lt;/td&gt;
&lt;td&gt;60 days&lt;/td&gt;
&lt;td&gt;&lt;code&gt;instagram/index.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facebook Page (Meta)&lt;/td&gt;
&lt;td&gt;No expiration date&lt;/td&gt;
&lt;td&gt;absent from &lt;code&gt;getAccountsToRefresh&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;60 days (5,184,000 s)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;__tests__/social/contract/linkedin.test.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TikTok&lt;/td&gt;
&lt;td&gt;24 hours&lt;/td&gt;
&lt;td&gt;&lt;code&gt;__tests__/social/contract/youtube.test.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube (Google)&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;&lt;code&gt;youtube.types.d.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;YouTube is the extreme case: with a token that lasts an hour, the hourly job is not enough on its own, so its SDK also renews on demand before every call. Instagram and LinkedIn, at 60 days, live comfortably inside the hourly pass with room to spare. Facebook Pages sit at the other end: their token has no date, so they never enter the pass. The practical consequence is that &lt;strong&gt;the renewal code cannot be generic&lt;/strong&gt;: each SDK knows how long its own token lasts and which field of the response carries the new expiry, because they are not even named the same across networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens the day the token really expires?
&lt;/h2&gt;

&lt;p&gt;The automatic renewal fails, and from that point the account stops trying on its own. This only happens if something prevented the 60-day cycle from closing in time (the user revoked the permission from Meta, the app lost its approved use case, or the job had been unable to run for more than a day), never from the mere passing of 60 days with the system working normally.&lt;/p&gt;

&lt;p&gt;When the renewal call fails, three things happen at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The account is flagged with an error code (&lt;code&gt;error_code: 707&lt;/code&gt;, "Error while sincronyzing").&lt;/li&gt;
&lt;li&gt;That code takes it out of the job's next pass: the query selecting accounts to renew requires &lt;code&gt;error_code: 0&lt;/code&gt;, so a downed account is not retried every hour against a token that no longer works.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;change_state_account&lt;/code&gt; change is queued and notified by signed webhook to the client apps that have that client registered.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The job processes every account due for renewal in that pass in parallel, grouped by client. That matters because a client with several Instagram accounts can have one fail and the rest renew without trouble in the same pass: one failure neither blocks nor delays the others, and the webhook that reaches the client app carries that client's changes grouped together, not one message per account.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/jobs/RefreshTokens.ts&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AccountModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CustomError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ERROR_CODE_707&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;addAccountChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grouped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_WEBHOOKS_NOTIFICATIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;change_state_account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;id_account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;id_organization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id_organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;social_network&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;social_network&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How does your product find out an account disconnected?
&lt;/h2&gt;

&lt;p&gt;Through a webhook, as soon as the job detects the failure. If your app integrates the PlanVortex API, the &lt;code&gt;change_state_account&lt;/code&gt; webhook reaches you with &lt;code&gt;id_account&lt;/code&gt;, &lt;code&gt;id_organization&lt;/code&gt; and &lt;code&gt;social_network&lt;/code&gt;, signed like every other outbound PlanVortex webhook.&lt;/p&gt;

&lt;p&gt;Finding out through a publishing error is too late: the user scheduled content for three days out assuming the account was still connected, and the failure does not surface until that content tries to go out. With the webhook, your product can warn the user the same day the account went down, with plenty of time to reconnect it before anything is left unpublished.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you have settled if you integrate Meta accounts into your product?
&lt;/h2&gt;

&lt;p&gt;Four things, before assuming a connected account is still connected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A disconnected account is a state, not an error. It happens in normal use (the user revokes the permission from Meta's settings, changes their password, the app loses access) and your product has to be able to show that without treating it as an exception.&lt;/li&gt;
&lt;li&gt;Listen to the webhook as well as to the publishing error. It is the difference between warning on the day the account goes down and warning on the day something scheduled fails because of it. The code that receives it (signature verification over the raw body included, which is where everyone trips) is in the &lt;a href="https://planvortex.com/en/resources/publish-social-media-node-js" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; and &lt;a href="https://planvortex.com/en/resources/publish-social-media-python" rel="noopener noreferrer"&gt;Python&lt;/a&gt; guides.&lt;/li&gt;
&lt;li&gt;Reconnection is always manual. Neither PlanVortex nor Meta can renew a permission the user has revoked; the flow to connect the account is the same one as the first time, authorization code included.&lt;/li&gt;
&lt;li&gt;The message to the user cannot be generic, because networks do not fail the same way. A downed TikTok account and a downed Instagram account share neither the most likely cause nor the urgency: with a 24-hour token, a TikTok failure is almost always a one-off problem with the job; with a 60-day one, an Instagram failure almost always means the user revoked the permission.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>facebook</category>
      <category>instagram</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Is the LinkedIn API free? Pricing and posting access in 2026</title>
      <dc:creator>Francisco José Fernández</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:17:24 +0000</pubDate>
      <link>https://dev.to/frandeveloper/is-the-linkedin-api-free-pricing-and-posting-access-in-2026-4im9</link>
      <guid>https://dev.to/frandeveloper/is-the-linkedin-api-free-pricing-and-posting-access-in-2026-4im9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq3wui5jd7a8h9qzdgho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq3wui5jd7a8h9qzdgho.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;LinkedIn charges nothing per API call, but posting to a company page takes its approval.&lt;/strong&gt; Posting to your own profile is self-serve. Company pages need the Community Management API, which LinkedIn grants only to registered companies after a review, and which starts with a cap of 500 calls a day for the whole app.&lt;/p&gt;
&lt;h2&gt;
  
  
  How much does the LinkedIn API cost?
&lt;/h2&gt;

&lt;p&gt;Nothing per call. LinkedIn's developer documentation describes access tiers and reviews, with no price list and no paid plan that buys more. What you do pay is a review before you can post to a company page, a daily cap while you are in the first tier, and upkeep: the API version you send expires, and so does every connection, once a year.&lt;/p&gt;

&lt;p&gt;There are three ways in:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Where it posts&lt;/th&gt;
&lt;th&gt;How you get it&lt;/th&gt;
&lt;th&gt;Daily limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Share on LinkedIn&lt;/td&gt;
&lt;td&gt;Your own profile (&lt;code&gt;w_member_social&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Self-serve: add the product to your app in the Developer Portal&lt;/td&gt;
&lt;td&gt;150 requests per member, 100,000 per app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Community Management API, Development tier&lt;/td&gt;
&lt;td&gt;Company pages and profiles&lt;/td&gt;
&lt;td&gt;An access form that LinkedIn reviews&lt;/td&gt;
&lt;td&gt;500 calls per app and 100 per member, every 24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Community Management API, Standard tier&lt;/td&gt;
&lt;td&gt;The same, in production&lt;/td&gt;
&lt;td&gt;A second form, with a screencast of your app&lt;/td&gt;
&lt;td&gt;No restrictions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Figures from LinkedIn's &lt;a href="https://learn.microsoft.com/en-us/linkedin/marketing/increasing-access" rel="noopener noreferrer"&gt;Increasing Access&lt;/a&gt; and &lt;a href="https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin" rel="noopener noreferrer"&gt;Share on LinkedIn&lt;/a&gt; pages, read on October 6, 2026.&lt;/p&gt;
&lt;h2&gt;
  
  
  Can you use the LinkedIn API for personal use?
&lt;/h2&gt;

&lt;p&gt;Only to post as yourself. Share on LinkedIn gives any app the &lt;code&gt;w_member_social&lt;/code&gt; permission without a review, and that is enough to publish to the profile of whoever authorizes it. For a script that posts your own updates, that is the route.&lt;/p&gt;

&lt;p&gt;Company pages are another matter. LinkedIn's review page says "our Community Management APIs are only available to registered legal organizations for commercial use cases only", and personal email addresses won't pass the vetting, so a side project has no way to post to a company page.&lt;/p&gt;

&lt;p&gt;Share on LinkedIn's documentation still shows the old &lt;code&gt;/v2/ugcPosts&lt;/code&gt; endpoint, while the current Posts API at &lt;code&gt;/rest/posts&lt;/code&gt; "replaces the ugcPosts API", in LinkedIn's own words. Write against &lt;code&gt;/rest/posts&lt;/code&gt; from the first day and there is nothing to migrate when you move up a tier.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you get access to post on a LinkedIn company page?
&lt;/h2&gt;

&lt;p&gt;You apply for the Community Management API from your app in the Developer Portal. For the Development tier, LinkedIn checks five things: an approved use case, a verified business email, a verified organization, a verified website and domain, and that the LinkedIn Page of that same organization has verified the app, which takes one of the Page's super admins.&lt;/p&gt;

&lt;p&gt;Three of its rules change how you plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A rejection is final for that app. In LinkedIn's words: "You won't be able to re-apply for Development tier access with your existing app." You create a new app and start over.&lt;/li&gt;
&lt;li&gt;The Development tier has a deadline: you have twelve months to apply for Standard, and after that LinkedIn removes your access for inactivity.&lt;/li&gt;
&lt;li&gt;The 500 calls a day are for the whole app, shared by all your customers. In our SDK, a post with one image takes four requests: start the upload, send the bytes, read the image back and create the post. Statistics and comments come on top. Batch reads (&lt;code&gt;BATCH_GET&lt;/code&gt;) are blocked, and webhooks for social actions stay off until Standard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Standard tier asks for more: test credentials, a high-resolution screencast that shows a user going through the full OAuth flow and each use case you declared (for page management, a post and how a comment on it shows up in your app), a valid privacy policy, and compliance with LinkedIn's data storage rules.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why does the LinkedIn API return 426?
&lt;/h2&gt;

&lt;p&gt;Because the &lt;code&gt;Linkedin-Version&lt;/code&gt; header asks for a version that LinkedIn has retired. The response is &lt;a href="https://learn.microsoft.com/en-us/linkedin/marketing/error-responses" rel="noopener noreferrer"&gt;documented&lt;/a&gt; like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;426&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NONEXISTENT_VERSION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Requested version yyyymmdd is not active"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LinkedIn publishes a version every month, in &lt;code&gt;YYYYMM&lt;/code&gt; format, and supports each one for at least a year. Leaving the header out does not get you the latest one: LinkedIn documents a 400 with the code &lt;code&gt;VERSION_MISSING&lt;/code&gt;. The oldest version still in the documentation, &lt;code&gt;202510&lt;/code&gt;, is sunset on October 15, 2026, and a header pinned to &lt;code&gt;202501&lt;/code&gt;, from January 2025, already gets the 426.&lt;/p&gt;

&lt;p&gt;The fix is to send a live version. In our SDK it lives in one constant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/orm/social_apis/linkedin/constant/index.ts&lt;/span&gt;
&lt;span class="nx"&gt;LINKEDIN_API_VERSION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;202606&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="c1"&gt;// and every request carries the same three headers&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Linkedin-Version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LINKEDIN_API_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Restli-Protocol-Version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the version in a single constant, note the date it expires in a shared calendar, and run at least one test against the real API: a test with mocked responses keeps passing after the version is retired.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is your LinkedIn post cut off with no error?
&lt;/h2&gt;

&lt;p&gt;Because the body of the post is not plain text. The &lt;code&gt;commentary&lt;/code&gt; field of &lt;code&gt;/rest/posts&lt;/code&gt; is written in &lt;a href="https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/little-text-format" rel="noopener noreferrer"&gt;little text&lt;/a&gt;, a small format for mentions and hashtags with fifteen reserved characters, and the documentation is explicit: "All reserved characters need to be escaped with a backslash, even if those characters are not used in one of the supported elements or templates."&lt;/p&gt;

&lt;p&gt;If you don't escape them, LinkedIn still answers 201 with the post id, and the post shows up cut off at the first unescaped character. A price in brackets, "(30€ + VAT)", is enough to take the rest of the post with it, links included. Nothing in the response or in your logs shows it; you only see it on LinkedIn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/orm/social_apis/linkedin/Text.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RESERVED_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\\&lt;/span&gt;&lt;span class="sr"&gt;|{}@&lt;/span&gt;&lt;span class="se"&gt;\[\]&lt;/span&gt;&lt;span class="sr"&gt;()&amp;lt;&amp;gt;#*_~&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The escaping has to be a single pass, because the backslash is reserved too: escape it in a second step and the &lt;code&gt;\(&lt;/code&gt; you just added gets published as &lt;code&gt;\\(&lt;/code&gt;. And a &lt;code&gt;#&lt;/code&gt; that opens a real hashtag must stay as it is, or the hashtag is published as dead text. A &lt;code&gt;#1&lt;/code&gt; that is just a number gets escaped like everything else.&lt;/p&gt;

&lt;p&gt;The post has two size limits as well. The body takes up to 3,000 characters. The title of an attached image or video, &lt;code&gt;content.media.title&lt;/code&gt;, takes 400, and past that LinkedIn answers 422 with &lt;code&gt;length of "..." is out of range 0...400&lt;/code&gt;. That title is not little text, so it is not escaped: escaping it would publish the backslashes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long does a LinkedIn access token last?
&lt;/h2&gt;

&lt;p&gt;60 days. Apps approved for LinkedIn's marketing programs also get a refresh token that lasts 365 days, and the &lt;a href="https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; is clear that using it does not extend it. Renew on day 59 and the refresh token has 306 days left; when it reaches zero, "the member must reauthorize your application". So every LinkedIn connection ends after a year, however well your renewal works. Apps without that approval get no refresh token at all, and the user has to go through the authorization screen every 60 days.&lt;/p&gt;

&lt;p&gt;The token belongs to the person who authorized the app, and it covers their profile and every page they administer. If you store one row per page, renew per person and write the new token to every page that came from that authorization.&lt;/p&gt;

&lt;p&gt;When a connection ends, handle it as the &lt;a href="https://planvortex.com/en/resources/meta-access-token-expiry" rel="noopener noreferrer"&gt;Meta token article&lt;/a&gt; describes: show the account as disconnected and ask the user to connect it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need your own LinkedIn app if you integrate through PlanVortex?
&lt;/h2&gt;

&lt;p&gt;No. Your customers connect their LinkedIn pages, and their personal profiles too, through PlanVortex's app, which has the Community Management API at the Standard tier, so the Development tier's cap of 500 calls a day does not apply to them. The access form, the screencast and the version header are our job. Two things stay on your side: the person authorizes again when LinkedIn asks for it, and your product finds out through the signed &lt;code&gt;change_state_account&lt;/code&gt; webhook as soon as a renewal fails.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://planvortex.com/en/developers" rel="noopener noreferrer"&gt;developers page&lt;/a&gt; shows the API, the Node and Python libraries and the MCP server that sit on top of that app. With the MCP server, Claude or Cursor can &lt;a href="https://planvortex.com/en/resources/linkedin-mcp-server" rel="noopener noreferrer"&gt;post to LinkedIn for you&lt;/a&gt;, to your profile and your pages.&lt;/p&gt;

&lt;p&gt;If you only want to post to your own company pages or your own profile, you don't need to write code at all: the panel does it, and &lt;a href="https://planvortex.com/en/pricing" rel="noopener noreferrer"&gt;the first three accounts are free&lt;/a&gt;. On paid plans, its AI planner also writes the week's posts, with images or from your own photos.&lt;/p&gt;

&lt;p&gt;It isn't the right tool if what you need is LinkedIn advertising. Campaigns and ad accounts live in the Advertising API, a separate product with its own review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do you go from here?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://planvortex.com/en/resources/publish-social-media-node-js" rel="noopener noreferrer"&gt;Publishing from Node.js&lt;/a&gt; and &lt;a href="https://planvortex.com/en/resources/publish-social-media-python" rel="noopener noreferrer"&gt;from Python&lt;/a&gt;: connect a customer's LinkedIn page and publish to it, webhook verification included.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://planvortex.com/en/resources/social-media-character-limits" rel="noopener noreferrer"&gt;Character limits across social APIs&lt;/a&gt;: which unit each network counts in, LinkedIn included.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://planvortex.com/en/resources/linkedin-mcp-server" rel="noopener noreferrer"&gt;A LinkedIn MCP server on the official API&lt;/a&gt;: post to your profile and pages from Claude, Cursor or VS Code.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>linkedin</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Character limits across social APIs: .length lies, and it lies both ways</title>
      <dc:creator>Francisco José Fernández</dc:creator>
      <pubDate>Wed, 16 Sep 2026 11:40:43 +0000</pubDate>
      <link>https://dev.to/frandeveloper/character-limits-across-social-apis-length-lies-and-it-lies-both-ways-3oh2</link>
      <guid>https://dev.to/frandeveloper/character-limits-across-social-apis-length-lies-and-it-lies-both-ways-3oh2</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;code&gt;"👨‍👩‍👧‍👦".length&lt;/code&gt; is 11.&lt;/strong&gt; That emoji is one character on screen, seven Unicode code points, and 25 bytes on the wire. Which of those numbers a social network measures your post against depends on the network — and if you pick the wrong one, you either reject posts that would publish fine or accept posts the API will refuse hours later, when the thing is already scheduled.&lt;/p&gt;

&lt;p&gt;This is what we learned building a publishing API against thirteen networks, with the numbers taken from the real APIs rather than from their documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three units, one string
&lt;/h2&gt;

&lt;p&gt;Every discussion about character limits is really a discussion about units. There are three that matter, and a fourth that looks useful and is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grapheme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;👨‍👩‍👧‍👦&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;                                  &lt;span class="c1"&gt;// 11  — UTF-16 code units&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;                             &lt;span class="c1"&gt;//  7  — Unicode code points&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;                &lt;span class="c1"&gt;//  1  — graphemes&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;        &lt;span class="c1"&gt;// 25  — UTF-8 bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Text&lt;/th&gt;
&lt;th&gt;&lt;code&gt;.length&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;code points&lt;/th&gt;
&lt;th&gt;graphemes&lt;/th&gt;
&lt;th&gt;bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;👨‍👩‍👧‍👦&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;🙂&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;é&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;漢&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The family emoji is four people joined by three zero-width joiners, which is why spreading the string — the trick everyone reaches for first — gives 7 and not 1. Code points are never the answer to anything here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which unit does each network count in?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Network&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Facebook&lt;/td&gt;
&lt;td&gt;63,206&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube (description)&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp&lt;/td&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram&lt;/td&gt;
&lt;td&gt;4,096, or &lt;strong&gt;1,024 with media&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;4,000&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;3,000&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instagram&lt;/td&gt;
&lt;td&gt;2,200&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TikTok&lt;/td&gt;
&lt;td&gt;2,200&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discord&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Threads&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;graphemes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;280&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bluesky&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;300 and 3,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;graphemes &lt;em&gt;and&lt;/em&gt; bytes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most networks count UTF-16 code units, which is exactly what &lt;code&gt;.length&lt;/code&gt; returns. That is the trap: &lt;code&gt;.length&lt;/code&gt; is right often enough that you stop questioning it, and then you meet the two networks where it is wrong.&lt;/p&gt;

&lt;p&gt;TikTok's row hides a second lesson. Its app lets you type 4,000 characters; its Content Posting API rejects past 2,200. The number that matters is the one belonging to the endpoint you actually call, never the one in the network's own UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bluesky counts two limits at once, in different units
&lt;/h2&gt;

&lt;p&gt;Bluesky is the only network with two text limits alive at the same time: &lt;strong&gt;300 graphemes and 3,000 bytes&lt;/strong&gt;, and a post has to pass both.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.length&lt;/code&gt; fails both, in opposite directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It over-counts emoji.&lt;/strong&gt; 121 family emoji are 121 graphemes — comfortably inside 300 — so they should publish. &lt;code&gt;.length&lt;/code&gt; says 1,331 and rejects them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It under-counts everything non-Latin.&lt;/strong&gt; Those same 121 emoji weigh 3,025 bytes, which is over the byte limit. Counting characters alone says they fit; the network says no. A long text in Japanese does the same thing: three bytes per character, and a post that looks short blows the byte ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the check has to be two checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;graphemes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countGraphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// Intl.Segmenter&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                   &lt;span class="c1"&gt;// Buffer.byteLength / TextEncoder&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;graphemes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// reject, and tell the user WHICH of the two it was&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Telling the user which limit they hit is not a nicety. "Too long" in front of a counter that says 180 characters is an unanswerable error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threads counts graphemes, and we only know because we measured
&lt;/h2&gt;

&lt;p&gt;The documentation says "500 characters" and adds, unhelpfully, that emoji count as UTF-8 bytes. Two readings, both plausible, and they disagree. So we sent two probes at a real account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;400 × &lt;code&gt;🙂&lt;/code&gt; — 400 graphemes, 800 UTF-16 units, 1,600 bytes → &lt;strong&gt;publishes&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;400 × &lt;code&gt;é&lt;/code&gt; — 400 graphemes, 400 UTF-16 units, 800 bytes → &lt;strong&gt;publishes&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are over 500 in at least one of the other two units, so neither UTF-16 nor bytes can be the ruler. It counts graphemes.&lt;/p&gt;

&lt;p&gt;Before that measurement our validation used &lt;code&gt;.length&lt;/code&gt;, which meant a post of 300 emoji was rejected as too long while the network would have accepted it. &lt;strong&gt;That is the expensive direction of the two.&lt;/strong&gt; A post the API refuses produces an error you can read; a post your own product refuses produces a user staring at a counter that says 300 of 500 and a message saying it is too long.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telegram changes its limit when you attach a photo
&lt;/h2&gt;

&lt;p&gt;Same text field, two numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4,096&lt;/strong&gt; with no attachment, because the text goes out as &lt;code&gt;sendMessage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1,024&lt;/strong&gt; the moment there is a photo or a video, because then the text is the &lt;code&gt;caption&lt;/code&gt; of &lt;a href="https://core.telegram.org/bots/api#sendphoto" rel="noopener noreferrer"&gt;&lt;code&gt;sendPhoto&lt;/code&gt;&lt;/a&gt;, &lt;code&gt;sendVideo&lt;/code&gt; or &lt;code&gt;sendMediaGroup&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not an edge case in a publishing tool, it is the normal case: someone writes 2,000 characters, likes it, drags in an image, and the post that was valid a second ago now fails with a 400 at publish time. The counter in the composer has to change the instant the file is attached, and the error — if it still happens — has to say that the limit dropped &lt;em&gt;because&lt;/em&gt; of the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Slack does not fail, which is worse
&lt;/h2&gt;

&lt;p&gt;Slack's limit is 4,000 characters for &lt;a href="https://api.slack.com/methods/chat.postMessage" rel="noopener noreferrer"&gt;&lt;code&gt;chat.postMessage&lt;/code&gt;&lt;/a&gt;, and going over does not produce an error. It truncates the message, or splits it into several. Your dashboard shows one published post; the channel holds two; nothing failed anywhere.&lt;/p&gt;

&lt;p&gt;Slack has a second trap in the same area. Its text has to be escaped — &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt; are the start of Slack's own entities, and an unescaped &lt;code&gt;&amp;lt;&lt;/code&gt; swallows the rest of the sentence — and escaping &lt;em&gt;changes the length&lt;/em&gt;. &lt;code&gt;&amp;amp;&lt;/code&gt; becomes &lt;code&gt;&amp;amp;amp;&lt;/code&gt;, five characters where there was one. So the 4,000 check counts the user's text, because that is what the composer counts, while any safety trim measures the escaped string, and has to cut without leaving half an entity behind. Cutting at character 4,000 flat once published a visible &lt;code&gt;&amp;amp;am&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to count each unit properly
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Graphemes: what a person calls "a character". */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;countGraphemes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grapheme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/** UTF-8 bytes: what the wire carries. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;countBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two practical notes. &lt;code&gt;Intl.Segmenter&lt;/code&gt; needs your TypeScript &lt;code&gt;lib&lt;/code&gt; at ES2022 or later — ours is pinned there while &lt;code&gt;target&lt;/code&gt; stays lower, purely for this. And segmenting is not free: it allocates a segment per grapheme, so count once and pass the number around rather than calling it in a loop over a thousand posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that prevents all of this
&lt;/h2&gt;

&lt;p&gt;Every bug above is cheap to fix once. The expensive one is structural: &lt;strong&gt;the screen that counts and the server that validates keeping separate copies of the numbers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ours drifted exactly that way. The composer counted LinkedIn to 3,000 while the server rejected at 1,300 — a number left over from an endpoint we no longer called — and the user found out at publish time, which is the worst moment available. The fix was not a better number. It was one source of truth, owned by whoever validates, and published:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.planvortex.com/v1.0.0/social_limits &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That endpoint returns the character limit, the byte limit, the title limit, image counts, video duration and file size for every network, so a composer never has to hardcode a single one of them. Whoever enforces a limit is who gets to announce it.&lt;/p&gt;

&lt;p&gt;If you are building the same thing, that is the piece worth copying even if you take nothing else from this article. And if you would rather not build it at all, &lt;a href="https://planvortex.com/en/developers" rel="noopener noreferrer"&gt;PlanVortex's API&lt;/a&gt; does the counting for you — in the right unit, per network — before anything reaches the network.&lt;/p&gt;




</description>
      <category>javascript</category>
      <category>api</category>
      <category>unicode</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
