<?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: Nikolas Dimitroulakis</title>
    <description>The latest articles on DEV Community by Nikolas Dimitroulakis (@nikolas_dimitroulakis_d23).</description>
    <link>https://dev.to/nikolas_dimitroulakis_d23</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%2F2866058%2Fc8488bb2-94d1-47b8-b37b-be1291c340c5.jpg</url>
      <title>DEV Community: Nikolas Dimitroulakis</title>
      <link>https://dev.to/nikolas_dimitroulakis_d23</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nikolas_dimitroulakis_d23"/>
    <language>en</language>
    <item>
      <title>Your API's newest users are agents...</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Fri, 25 Sep 2026 08:14:55 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/we-described-our-api-twice-once-for-humans-once-for-agents-4e4g</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/we-described-our-api-twice-once-for-humans-once-for-agents-4e4g</guid>
      <description>&lt;p&gt;Who called your API last?&lt;/p&gt;

&lt;p&gt;For us, the honest answer is more and more often an agent. Claude Code debugging a flow. An assistant looking up an order. Someone's custom GPT.&lt;/p&gt;

&lt;p&gt;Our tools still assume a human pressing Send.&lt;/p&gt;

&lt;p&gt;This post is about that gap. How we ran into it ourselves, how most teams handle it today, and what we changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we ran into it
&lt;/h2&gt;

&lt;p&gt;We run ApyHub, a catalog of utility APIs. This year we wanted assistants like Claude, ChatGPT, and Le Chat to call those APIs directly. So we built an MCP server and published it to the official MCP registry.&lt;/p&gt;

&lt;p&gt;It worked. People used it.&lt;/p&gt;

&lt;p&gt;Then we noticed something uncomfortable. We now had two descriptions of the same API.&lt;/p&gt;

&lt;p&gt;One was the requests and tests we already trusted. The other was the MCP server, written by hand, sitting next to them.&lt;/p&gt;

&lt;p&gt;Change an endpoint? Update both. Forget one? Find out when an agent breaks.&lt;/p&gt;

&lt;p&gt;Testing the MCP side was its own chore. Connect an inspector, click a tool, read the JSON, close the tab. Next release, repeat from memory.&lt;/p&gt;

&lt;p&gt;None of this is dramatic. It's just the kind of friction that adds up quietly until you notice you've built a second, worse copy of something you already had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three situations we kept hitting
&lt;/h2&gt;

&lt;p&gt;When we looked closer, it was really three different problems. They all involve MCP, which is why they're easy to blur together. The difference is who is calling whom.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Your own coding agent, debugging blind
&lt;/h3&gt;

&lt;p&gt;Checkout returns a 400. You ask Claude Code for help.&lt;/p&gt;

&lt;p&gt;Today, you paste the error. Maybe a curl command. The agent reasons about what &lt;em&gt;might&lt;/em&gt; be wrong. When it wants to test something, it writes its own curl, guesses the headers, and asks where the token lives.&lt;/p&gt;

&lt;p&gt;You end up being the agent's hands.&lt;/p&gt;

&lt;p&gt;What we wanted was simpler. The requests already exist in the project. Let the agent run them.&lt;/p&gt;

&lt;p&gt;So that's what we built. We use Voiden for this, the open-source API tool we work on, where requests live as plain Markdown files in the repo. One button (or &lt;code&gt;voiden agent&lt;/code&gt; in the terminal) and your coding agent can list, run, and inspect those requests. It calls the real endpoint and reads the real response. In our checkout example, it spots the missing header on its own.&lt;/p&gt;

&lt;p&gt;This is on for the whole project by default. Our reasoning: it's you, your editor, and your own agent. Small trust boundary.&lt;/p&gt;

&lt;p&gt;I'm fairly confident about that. But I know some teams will disagree, especially with production credentials in the environment. If that's you, I'd like to hear where you'd draw the line.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Someone else's agent, calling one endpoint
&lt;/h3&gt;

&lt;p&gt;Your support assistant needs to issue refunds. Only refunds.&lt;/p&gt;

&lt;p&gt;Today, someone writes an MCP server. Picks an SDK. Redefines the inputs as a schema. Wires up auth. Figures out secrets. Hosts it.&lt;/p&gt;

&lt;p&gt;Now there's a new codebase describing an API you already had. It drifts. Nobody notices until an agent sends a field that was renamed two sprints ago.&lt;/p&gt;

&lt;p&gt;This was exactly our ApyHub story.&lt;/p&gt;

&lt;p&gt;What we do now: take the refund request that's already written and tested. Mark it as a tool. Choose which values the agent can set, in this case just the order ID. Secrets stay in your environment. That's the whole server.&lt;/p&gt;

&lt;p&gt;Nothing is exposed until you mark it. Once other people's agents are involved, opt-in felt like the only honest default.&lt;/p&gt;

&lt;p&gt;One rule is more opinionated. A tool is only available while its tests pass. Test goes red, tool goes offline.&lt;/p&gt;

&lt;p&gt;That's strict. With a flaky test, it will annoy you. We chose it anyway, because an agent losing access felt better than an agent calling something nobody has verified lately.&lt;/p&gt;

&lt;p&gt;Is that the right trade? I genuinely go back and forth. Curious what others think.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Testing an MCP server, yours or a vendor's
&lt;/h3&gt;

&lt;p&gt;Two versions of this. You run your own MCP server and want to know it still works before a release. Or you're about to build on a vendor's server and want to see what it really returns.&lt;/p&gt;

&lt;p&gt;Today: an inspector tab. Click, read, close. Gone. Or a one-off script that lives in someone's home folder.&lt;/p&gt;

&lt;p&gt;What we wanted was the same thing we already had for REST. Save the call. Add assertions. Run it in CI.&lt;/p&gt;

&lt;p&gt;So now an MCP call is just another block in the file. Point it at a server, pick a tool, check the response with the same auth and assertions you'd use anywhere else. Before a release, it confirms &lt;code&gt;search_orders&lt;/code&gt; still returns the shape you expect. For a vendor's server, like Notion's, the file becomes a working reference the team can rerun.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I care about most
&lt;/h2&gt;

&lt;p&gt;All three live in one file.&lt;/p&gt;

&lt;p&gt;The request you test with is the one your agent runs. It's the one you publish as a tool. And it sits right next to your MCP tests.&lt;/p&gt;

&lt;p&gt;One description of the API, instead of three that slowly disagree.&lt;/p&gt;

&lt;p&gt;Because it's a plain file in the repo, changes go through a pull request. If someone widens what the refund tool accepts, a reviewer sees it in the diff.&lt;/p&gt;

&lt;p&gt;That's the real point for me. Giving an agent access to an API is a permission decision. Permission decisions deserve a review trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we keep building this
&lt;/h2&gt;

&lt;p&gt;Voiden isn't our core business. ApyHub pays for it. We build it because we use it every day, and when something bugs us enough, it ends up in the next release.&lt;/p&gt;

&lt;p&gt;This one bugged us for months.&lt;/p&gt;

&lt;p&gt;It shipped in 2.3, along with a few other things. The &lt;a href="https://voiden.md/changelog" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; has the full list if you're curious.&lt;/p&gt;




&lt;p&gt;An open question, since I don't think anyone has this fully figured out yet: &lt;/p&gt;

&lt;p&gt;how are you deciding what agents can touch in your APIs today? Config file? Gateway? Hand-written MCP server? Gut feeling?&lt;/p&gt;

&lt;p&gt;I'd like to hear what's working, and what isn't.&lt;/p&gt;

&lt;p&gt;If you want to poke at it: &lt;a href="https://voiden.md/download" rel="noopener noreferrer"&gt;voiden.md&lt;/a&gt; · &lt;a href="https://github.com/VoidenHQ/voiden" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>mcp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Create calendar invites in Node.js that update and cancel cleanly</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Tue, 22 Sep 2026 16:50:19 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/create-calendar-invites-in-nodejs-that-update-and-cancel-cleanly-17dh</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/create-calendar-invites-in-nodejs-that-update-and-cancel-cleanly-17dh</guid>
      <description>

&lt;p&gt;Create calendar invites in Node.js that update and cancel cleanly&lt;/p&gt;

&lt;p&gt;Send .ics calendar invites from Node.js with Nodemailer, then move and cancel them without leaving duplicate events in anyone's calendar.&lt;/p&gt;

&lt;p&gt;Sending one calendar invite is easy. The bugs show up on the second email.&lt;/p&gt;

&lt;p&gt;You move the meeting by an hour and every attendee now has two events. You cancel it and the event stays on everyone's calendar. You send it from Gmail and the Accept and Decline buttons never appear.&lt;/p&gt;

&lt;p&gt;All three come from the same place: the rules for how calendar clients match an update to the original event. Those rules live in &lt;a href="https://datatracker.ietf.org/doc/html/rfc5546" rel="noopener noreferrer"&gt;RFC 5546 (iTIP)&lt;/a&gt;, which sits on top of the file format in &lt;a href="https://www.ietf.org/rfc/rfc5545" rel="noopener noreferrer"&gt;RFC 5545&lt;/a&gt;. This post builds a small Node.js module that creates, updates and cancels an invite and gets those rules right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three rules that prevent duplicate events
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the UID.&lt;/strong&gt; Every event has a &lt;code&gt;UID&lt;/code&gt;. An update or a cancellation must reuse it. A new UID means a new event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Increment SEQUENCE.&lt;/strong&gt; RFC 5546 says the organizer MUST increment &lt;code&gt;SEQUENCE&lt;/code&gt; when the start, end, recurrence or status changes. Clients ignore an update whose sequence is not higher than what they already have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match METHOD.&lt;/strong&gt; &lt;code&gt;METHOD:REQUEST&lt;/code&gt; is an invitation, &lt;code&gt;METHOD:CANCEL&lt;/code&gt; is a cancellation. The email's calendar part must carry the same method as the file, or clients treat it as a plain attachment.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;create  ─► UID=abc  SEQUENCE=0  METHOD:REQUEST
update  ─► UID=abc  SEQUENCE=1  METHOD:REQUEST   (replaces, no duplicate)
cancel  ─► UID=abc  SEQUENCE=2  METHOD:CANCEL    (removes the event)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three ways to generate the .ics
&lt;/h2&gt;

&lt;p&gt;You need the &lt;code&gt;.ics&lt;/code&gt; text before you can send it. There are three common routes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Trade-offs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hand-written string&lt;/td&gt;
&lt;td&gt;No dependencies, full control&lt;/td&gt;
&lt;td&gt;You own line folding, escaping, &lt;code&gt;DTSTAMP&lt;/code&gt; and time zone blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/adamgibbons/ics" rel="noopener noreferrer"&gt;&lt;code&gt;ics&lt;/code&gt; npm package&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Open source (ISC), runs locally, supports &lt;code&gt;uid&lt;/code&gt;, &lt;code&gt;sequence&lt;/code&gt;, &lt;code&gt;method&lt;/code&gt;, attendees, alarms and recurrence&lt;/td&gt;
&lt;td&gt;Times go in as local or UTC (&lt;code&gt;startInputType&lt;/code&gt;), so converting from a named zone is your job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apyhub.com/apyhub/service/generate-ical-event" rel="noopener noreferrer"&gt;ICS generator API&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;JSON in, &lt;code&gt;.ics&lt;/code&gt; out; accepts IANA zones like &lt;code&gt;Europe/Athens&lt;/code&gt;; recurrence, reminders and attendees as fields; cancellation with one query parameter&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Good to know:&lt;/strong&gt; it is a network call and needs an ApyHub API key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Hand-written strings&lt;/strong&gt; are fine for a single static event. The format has sharp edges, though. RFC 5545 requires lines to be folded at 75 octets and specific characters to be escaped, and a missing detail often fails silently in one client only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;ics&lt;/code&gt; package&lt;/strong&gt; is a solid choice when you want everything local and your times are already in UTC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API route&lt;/strong&gt; fits when events come from users in many time zones, or when an AI agent is the one creating them. It is what the code below uses.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; you send the meeting date, start time and a zone name, and it returns the finished file. The same request body with &lt;code&gt;?event_type=cancel&lt;/code&gt; produces the cancellation. There are two endpoints: &lt;a href="https://apyhub.com/apyhub/service/generate-ical-event" rel="noopener noreferrer"&gt;one returns the .ics file&lt;/a&gt; for email attachments, and one returns a signed download link for "Add to calendar" buttons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; apps that send invites on behalf of users, booking flows, and agents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;invites &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;invites
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm i nodemailer
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;APY_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_token_here
&lt;span class="c"&gt;# optional, to send real email:&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SMTP_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"smtps://user:pass@smtp.example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;SMTP_URL&lt;/code&gt;, the script uses &lt;a href="https://nodemailer.com/" rel="noopener noreferrer"&gt;Nodemailer's&lt;/a&gt; JSON transport and prints each message instead of sending it. That is useful for a dry run. You need Node 18 or later for the built-in &lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;Save this as &lt;code&gt;invite.mjs&lt;/code&gt;:&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="c1"&gt;// invite.mjs&lt;/span&gt;
&lt;span class="c1"&gt;// Create, update and cancel a calendar invite from Node.js.&lt;/span&gt;
&lt;span class="c1"&gt;// Usage: APY_TOKEN=your_token node invite.mjs&lt;/span&gt;
&lt;span class="c1"&gt;// Set SMTP_URL (for example smtps://user:pass@smtp.example.com) to send real email.&lt;/span&gt;
&lt;span class="c1"&gt;// Without it, Nodemailer prints the message as JSON instead of sending it.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nodemailer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodemailer&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;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.eu.apyhub.com/apyhub/generate-ical-event/download&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;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APY_TOKEN&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;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SMTP_URL&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;nodemailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SMTP_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nodemailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;jsonTransport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Turn event details into .ics text&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildIcs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&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;cancel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event_type&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;cancel&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&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;apy-token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ICS generation failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Nodemailer's method must match the METHOD line inside the file,&lt;/span&gt;
&lt;span class="c1"&gt;// so read it from the file instead of hardcoding it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;methodOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^METHOD:&lt;/span&gt;&lt;span class="se"&gt;(\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/m&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUBLISH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&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;method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;methodOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ics&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;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizer_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attendees_emails&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; on &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meeting_date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time_zone&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;icalEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invite.ics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ics&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createInvite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&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;ics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;buildIcs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Invitation: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Same id, higher sequence: calendars replace the event instead of adding a copy&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateInvite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequence&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;ics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;buildIcs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Updated: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cancelInvite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequence&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;ics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;buildIcs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Canceled: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;span class="c1"&gt;// Demo: create, move by an hour, then cancel&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invite.mjs&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;team-sync-2026@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// store this with your meeting record&lt;/span&gt;
    &lt;span class="na"&gt;sequence&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="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Weekly team sync&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;meeting_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-06&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;09:00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;09:30&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;time_zone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Europe/Athens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://meet.example.com/team-sync&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;organizer_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;organizer@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;attendees_emails&lt;/span&gt;&lt;span class="p"&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;dev1@example.com&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;dev2@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;recurring&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;recurrence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WEEKLY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;reminders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;display&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minutes_before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createInvite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateInvite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10:00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10:30&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;update&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;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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;canceled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cancelInvite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancel&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;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;canceled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;canceled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node invite.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;create&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REQUEST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;fc8efa38-...@example.com&amp;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;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REQUEST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;cancel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CANCEL&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;One UID, three emails, sequence 0 to 2. Each attendee ends up with one event that moves and then disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions worth copying
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Store &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;sequence&lt;/code&gt; with your meeting record.&lt;/strong&gt; They are the only link between the email you sent last week and the one you send today. Put them in the same table as the meeting, and increment &lt;code&gt;sequence&lt;/code&gt; in the same transaction as the change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read METHOD from the file.&lt;/strong&gt; &lt;a href="https://nodemailer.com/message/calendar-events/" rel="noopener noreferrer"&gt;Nodemailer's calendar docs&lt;/a&gt; say the &lt;code&gt;method&lt;/code&gt; option should match the &lt;code&gt;METHOD&lt;/code&gt; inside the &lt;code&gt;.ics&lt;/code&gt;. Parsing it from the generated file means the two stay in sync. If the file has no &lt;code&gt;METHOD&lt;/code&gt; line, the code falls back to &lt;code&gt;PUBLISH&lt;/code&gt;, and clients will show a plain "add to calendar" file with no RSVP buttons. Log that case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the email simple.&lt;/strong&gt; The same Nodemailer page recommends only &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;html&lt;/code&gt; and a single &lt;code&gt;icalEvent&lt;/code&gt; for the best client compatibility. Extra attachments are a common reason RSVP buttons disappear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a zone name, never an offset.&lt;/strong&gt; &lt;code&gt;Europe/Athens&lt;/code&gt; handles daylight saving on its own. &lt;code&gt;+03:00&lt;/code&gt; is wrong for half the year, and a weekly recurring meeting will cross that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common problems and fixes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Update creates a second event&lt;/td&gt;
&lt;td&gt;New UID on the update&lt;/td&gt;
&lt;td&gt;Reuse the stored &lt;code&gt;id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update is ignored&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SEQUENCE&lt;/code&gt; not incremented&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sequence + 1&lt;/code&gt; on every change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancel does nothing&lt;/td&gt;
&lt;td&gt;Missing &lt;code&gt;METHOD:CANCEL&lt;/code&gt;, or old sequence&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;?event_type=cancel&lt;/code&gt; with the same &lt;code&gt;id&lt;/code&gt; and a higher &lt;code&gt;sequence&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No Accept/Decline buttons&lt;/td&gt;
&lt;td&gt;Method mismatch, or extra attachments&lt;/td&gt;
&lt;td&gt;Match &lt;code&gt;icalEvent.method&lt;/code&gt; to the file; send only text, html and the invite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meeting one hour off after a DST change&lt;/td&gt;
&lt;td&gt;Fixed UTC offset&lt;/td&gt;
&lt;td&gt;Send an IANA zone name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Letting an AI agent send invites
&lt;/h2&gt;

&lt;p&gt;Scheduling is one of the first jobs people hand to agents: "book 30 minutes with the team next Tuesday." The agent needs a reliable way to turn that into a real invite.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apyhub.com/apyhub/service/generate-ical-event" rel="noopener noreferrer"&gt;calendar invite generator&lt;/a&gt; is available through &lt;a href="https://apyhub.com/mcp" rel="noopener noreferrer"&gt;ApyHub MCP&lt;/a&gt;, like every endpoint in the catalog. An agent can discover it, read its parameters, and call it directly without a hand-written wrapper or tool definition. Pair it with your own tool that stores &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;sequence&lt;/code&gt;, and the agent can reschedule and cancel without creating duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Validate attendee addresses before sending with an &lt;a href="https://apyhub.com/apyhub/service/verify-email-validity-and-deliverability" rel="noopener noreferrer"&gt;email validation and deliverability check&lt;/a&gt;, so invites to typos do not bounce.&lt;/li&gt;
&lt;li&gt;For an "Add to calendar" button on a web page, use the &lt;a href="https://apyhub.com/apyhub/service/generate-ical-event" rel="noopener noreferrer"&gt;signed .ics download link endpoint&lt;/a&gt; with the same request body and point the button at the returned URL.&lt;/li&gt;
&lt;li&gt;Browse the rest of the &lt;a href="https://apyhub.com/catalog" rel="noopener noreferrer"&gt;API catalog&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I create a calendar invite in Node.js?&lt;/strong&gt;&lt;br&gt;
Generate an &lt;code&gt;.ics&lt;/code&gt; file with a stable UID, then send it with Nodemailer's &lt;code&gt;icalEvent&lt;/code&gt; option and &lt;code&gt;method: "REQUEST"&lt;/code&gt;. The code above does both, using an &lt;a href="https://apyhub.com/apyhub/service/generate-ical-event" rel="noopener noreferrer"&gt;ICS file generator API&lt;/a&gt; for the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I update a calendar invite without creating a duplicate?&lt;/strong&gt;&lt;br&gt;
Send a new &lt;code&gt;.ics&lt;/code&gt; with the same UID and a higher &lt;code&gt;SEQUENCE&lt;/code&gt;. Clients replace the existing event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I cancel a calendar invite?&lt;/strong&gt;&lt;br&gt;
Send &lt;code&gt;METHOD:CANCEL&lt;/code&gt; with the same UID and an incremented &lt;code&gt;SEQUENCE&lt;/code&gt;. With the API, add &lt;code&gt;?event_type=cancel&lt;/code&gt; to the same request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How can I test these APIs before writing code?&lt;/strong&gt;&lt;br&gt;
Run the request from the &lt;a href="https://apyhub.com/apyhub/service/generate-ical-event" rel="noopener noreferrer"&gt;API's page in the catalog&lt;/a&gt;, send the curl command from your terminal, or open it in &lt;a href="https://voiden.md/" rel="noopener noreferrer"&gt;Voiden&lt;/a&gt;, the open-source API client, add your API key and run it. The free plan covers testing: 5 calls a day and 3,000 atoms a month, with no credit card. The demo above uses 3 calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with Google Calendar, Outlook and Apple Calendar?&lt;/strong&gt;&lt;br&gt;
They all read RFC 5545 files and follow the UID and SEQUENCE rules. Send one test invite to each before launch, since each client renders the email a little differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  About ApyHub
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apyhub.com/" rel="noopener noreferrer"&gt;ApyHub&lt;/a&gt; is a curated API catalog and trusted operational layer for developers and AI agents, with over 1,500 endpoints and capabilities, and it keeps growing. Every API is verified before listing and carries machine-readable certification for GDPR, SOC 2 and ISO 27001. One subscription covers the catalog, every endpoint is MCP-ready by default, and the free plan needs no credit card.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build a phishing link checker in Node.js with three API calls</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Tue, 22 Sep 2026 16:35:18 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/build-a-phishing-link-checker-in-nodejs-with-three-api-calls-2pb4</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/build-a-phishing-link-checker-in-nodejs-with-three-api-calls-2pb4</guid>
      <description>&lt;p&gt;Every app that lets users paste a link has the same problem. Someone will eventually paste a phishing link, and your app will render it as a clickable, trustworthy-looking element inside your UI.&lt;/p&gt;

&lt;p&gt;The volume is high. The Anti-Phishing Working Group counted 971,181 phishing attacks in the first quarter of 2026, up 13.8% from the quarter before (&lt;a href="https://docs.apwg.org/reports/apwg_trends_report_q1_2026.pdf" rel="noopener noreferrer"&gt;APWG Phishing Activity Trends Report, Q1 2026&lt;/a&gt;, May 2026). Most of those attacks end in a URL.&lt;/p&gt;

&lt;p&gt;This post builds a small link checker you can drop into a chat app, a comment system, a CRM or an AI agent. It takes any URL and returns &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;warn&lt;/code&gt; or &lt;code&gt;block&lt;/code&gt;, plus the reasons. It uses three API calls and a few checks that cost nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the checker does
&lt;/h2&gt;

&lt;p&gt;A phishing link usually has three traits you can test for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It hides where it goes.&lt;/strong&gt; Short links and redirect chains mask the real destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is already known.&lt;/strong&gt; Many phishing URLs get reported to threat databases within hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is new.&lt;/strong&gt; Attackers register a lookalike domain, run a campaign for a few days and move on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the pipeline is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input URL
   │
   ▼
1. Expand redirects ──► final URL
   │
   ├──► 2. Threat lookup (known malicious?)   ─┐
   │                                            ├──► score ──► allow / warn / block
   └──► 3. Domain age (registered last month?) ─┘
        + local checks (https, raw IP, punycode)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 2 and 3 are independent, so they run in parallel.&lt;/p&gt;

&lt;p&gt;The order matters. If you check the short link itself, you are checking &lt;code&gt;bit.ly&lt;/code&gt;, which is always clean. Every check has to run on the final destination.&lt;/p&gt;

&lt;h2&gt;
  
  
  The APIs
&lt;/h2&gt;

&lt;p&gt;All three calls go through &lt;a href="https://apyhub.com/catalog" rel="noopener noreferrer"&gt;ApyHub&lt;/a&gt; with one API key:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it returns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Expand&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apyhub.com/dosvak/service/unshorten-url" rel="noopener noreferrer"&gt;Resolve Short URL API&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Final URL, full redirect chain, a &lt;code&gt;truncated&lt;/code&gt; flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threat lookup&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apyhub.com/apyhub/service/generate-link-preview" rel="noopener noreferrer"&gt;Generate Link Preview API&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Threat type if the URL is in a malicious-URL database, page metadata if it is clean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain age&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apyhub.com/dosvak/service/domain-age" rel="noopener noreferrer"&gt;Domain Age API&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;WHOIS creation date and age in days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Link Preview API is doing double duty. With &lt;code&gt;secure_mode: true&lt;/code&gt; it checks the URL against a malicious-URL database before it fetches anything. A flagged URL comes back 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;"data"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://example-malicious-site.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"threat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"malware"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reported_malicious"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;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;A clean URL comes back with the page title, description and images, so the same call gives you a safe preview card to show your users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;link-checker &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;link-checker
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm i tldts
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;APY_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_token_here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tldts&lt;/code&gt; extracts the registrable domain from a hostname. You want the age of &lt;code&gt;acme.co.uk&lt;/code&gt;, and a naive split on dots would give you &lt;code&gt;co.uk&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You need Node 18 or later for the built-in &lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;Save this as &lt;code&gt;check-link.mjs&lt;/code&gt;:&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="c1"&gt;// check-link.mjs&lt;/span&gt;
&lt;span class="c1"&gt;// Usage: APY_TOKEN=your_token node check-link.mjs "https://bit.ly/example"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getDomain&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tldts&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;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.eu.apyhub.com&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;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&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;apy-token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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;application/json&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; returned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 1: follow every redirect to the real destination&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unshorten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/dosvak/unshorten-url&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeout_s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: check the destination against a malicious-URL database&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/apyhub/generate-link-preview&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secure_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Step 3: how old is the registrable domain?&lt;/span&gt;
&lt;span class="c1"&gt;// WHOIS data is patchy, so a failed lookup counts as "unknown", not as a crash&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domainAge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;domain&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/dosvak/domain-age?domain=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;age_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;age_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Free checks that need no API call&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;localSignals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;}&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalUrl&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;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&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;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no_https&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{1,3}(\.\d{1,3}){3}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ip_host&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]);&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;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&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="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xn--&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;punycode_host&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&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;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;unshorten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;finalUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolved&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;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "login.acme.co.uk" -&amp;gt; "acme.co.uk"&lt;/span&gt;

  &lt;span class="c1"&gt;// Steps 2 and 3 are independent, so run them in parallel&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;domainAge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)]);&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;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reported_malicious&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`threat:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threat&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;localSignals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;);&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;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;truncated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redirect_chain_truncated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]);&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;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;long_redirect_chain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&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;age&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domain_age_unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;else&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;age&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domain_under_30_days&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;else&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;age&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domain_under_6_months&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[,&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;weight&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warn&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;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;domainAgeDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age_days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;name&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node check-link.mjs &lt;span class="s2"&gt;"https://bit.ly/example"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the output looks like
&lt;/h2&gt;

&lt;p&gt;A short link that lands on a nine-day-old domain over plain HTTP:&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;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"finalUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://secure-acme-login.co.uk/verify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"secure-acme-login.co.uk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domainAgeDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sign in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"no_https"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"domain_under_30_days"&lt;/span&gt;&lt;span class="p"&gt;]&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;A link that is already in the threat database:&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;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"block"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"finalUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://evil.example/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"threat:malware"&lt;/span&gt;&lt;span class="p"&gt;]&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;A normal link:&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;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"finalUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domainAgeDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GitHub"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasons"&lt;/span&gt;&lt;span class="p"&gt;:&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;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;The &lt;code&gt;reasons&lt;/code&gt; array is the useful part. Show it to your moderators, log it, or turn it into the warning text users see ("This link goes to a site registered 9 days ago").&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the scoring works this way
&lt;/h2&gt;

&lt;p&gt;The weights are a starting point. Tune them against your own traffic. A few decisions are worth explaining.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A threat-database hit blocks outright.&lt;/strong&gt; This is the one signal with very few false positives, so it skips the score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain age warns and never blocks.&lt;/strong&gt; New domains are the strongest signal for campaigns that no database has seen yet. They are also every startup that launched last month. A warning label ("new site, check before you log in") handles both cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unknown age is a mild signal.&lt;/strong&gt; Some country-code TLDs do not publish WHOIS creation dates. Treating &lt;code&gt;null&lt;/code&gt; as "suspicious" would flag half of some regions. Treating it as "safe" would give attackers an easy gap. A weight of 1 splits the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raw IPs and punycode add weight.&lt;/strong&gt; Legitimate sites rarely send users to &lt;code&gt;http://185.x.x.x/login&lt;/code&gt;. Punycode (&lt;code&gt;xn--&lt;/code&gt;) hostnames are how lookalike characters like a Cyrillic "а" get into a domain. Both are free to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it cheaper
&lt;/h2&gt;

&lt;p&gt;Each check costs three API calls. Three changes cut that a lot in production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip the expand step for direct links.&lt;/strong&gt; Only call the unshortener when the hostname is a known shortener or when the link came from an untrusted source.&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;SHORTENERS&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bit.ly&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;t.co&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;tinyurl.com&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;ow.ly&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;is.gd&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;buff.ly&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;needsExpand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SHORTENERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cache domain age by domain.&lt;/strong&gt; A domain's creation date does not change. Cache it for a day and your hundredth link to &lt;code&gt;github.com&lt;/code&gt; costs nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check on submit, then store the verdict.&lt;/strong&gt; Run the check once when a user posts the link, save the result with the message, and render from the stored verdict. Re-checking on every page view multiplies your costs by your read traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not catch
&lt;/h2&gt;

&lt;p&gt;Be clear with yourself about the gaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lookalikes on old domains.&lt;/strong&gt; A compromised WordPress site from 2014 passes the age check. The threat database is your only defense there, and it lags new campaigns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean first hops.&lt;/strong&gt; Some kits show a harmless page to scanners and a login form to real visitors. No URL-level check sees that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messages without links.&lt;/strong&gt; Phone-number scams, fake invoices with bank details and voice phishing never touch this pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat the checker as one layer. It removes the obvious cases before a person has to judge them. It does not replace user reports, rate limits on new accounts or a way to pull a message after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it from an AI agent
&lt;/h2&gt;

&lt;p&gt;AI agents now read inboxes, open links and fill in forms on a user's behalf. An agent that follows a phishing link does it faster and with less hesitation than a person.&lt;/p&gt;

&lt;p&gt;The same three checks work as a gate before an agent acts on any URL. Every endpoint used here is available through &lt;a href="https://apyhub.com/mcp" rel="noopener noreferrer"&gt;ApyHub MCP&lt;/a&gt;, so an agent can discover, evaluate and call the &lt;a href="https://apyhub.com/dosvak/service/unshorten-url" rel="noopener noreferrer"&gt;Resolve Short URL API&lt;/a&gt;, the &lt;a href="https://apyhub.com/apyhub/service/generate-link-preview" rel="noopener noreferrer"&gt;Generate Link Preview API&lt;/a&gt; and the &lt;a href="https://apyhub.com/dosvak/service/domain-age" rel="noopener noreferrer"&gt;Domain Age API&lt;/a&gt; directly, without a hand-written wrapper or tool definition. A system prompt line such as "check every URL with the link tools before opening it, and stop on any threat result" is enough to wire it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;If links are only part of your abuse problem, a few related checks slot into the same pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apyhub.com/dosvak/service/domain-whois" rel="noopener noreferrer"&gt;Domain WHOIS Lookup API&lt;/a&gt; for the registrar and full registration record when a domain gets a warning.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apyhub.com/boomlify/service/temporary-and-disposable-emails-api" rel="noopener noreferrer"&gt;Temporary and Disposable Emails API&lt;/a&gt; to stop throwaway accounts that post phishing links in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apyhub.com/quadlem/service/verify-all-in-one" rel="noopener noreferrer"&gt;Identity &amp;amp; Fraud Verification API&lt;/a&gt; for a single risk score across email, IP and phone at signup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I check if a link is safe before opening it?&lt;/strong&gt;&lt;br&gt;
Expand it to the final URL first, then check that URL against a threat database and look at how old its domain is. The &lt;a href="https://apyhub.com/dosvak/service/unshorten-url" rel="noopener noreferrer"&gt;Resolve Short URL API&lt;/a&gt;, &lt;a href="https://apyhub.com/apyhub/service/generate-link-preview" rel="noopener noreferrer"&gt;Generate Link Preview API&lt;/a&gt; in secure mode and &lt;a href="https://apyhub.com/dosvak/service/domain-age" rel="noopener noreferrer"&gt;Domain Age API&lt;/a&gt; cover those three steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a new domain always a phishing domain?&lt;/strong&gt;&lt;br&gt;
No. New domains carry more risk, and most legitimate sites were new once. That is why the checker warns on domain age and blocks only on a threat-database match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does my server visit the phishing site when I expand the link?&lt;/strong&gt;&lt;br&gt;
The redirect-following happens on the API side, so your own servers never connect to the suspicious host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How can I test these APIs before writing code?&lt;/strong&gt;&lt;br&gt;
Run each one from its page in the &lt;a href="https://apyhub.com/catalog" rel="noopener noreferrer"&gt;ApyHub catalog&lt;/a&gt;, send the curl request from your terminal, or open the request in &lt;a href="https://voiden.md/" rel="noopener noreferrer"&gt;Voiden&lt;/a&gt;, the open-source API client, add your API key and run it. The free plan works for testing: it allows 5 calls a day, which is enough for one full link check plus a couple of single calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run this on every chat message?&lt;/strong&gt;&lt;br&gt;
Yes, if you check once on submit and cache domain age. See "Making it cheaper" above.&lt;/p&gt;

&lt;h2&gt;
  
  
  About ApyHub
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apyhub.com/" rel="noopener noreferrer"&gt;ApyHub&lt;/a&gt; is a curated API catalog and trusted operational layer for developers and AI agents. The catalog offers over 1,500 endpoints and capabilities, and it keeps growing. Every API is verified before listing and carries machine-readable certification for GDPR, SOC 2 and ISO 27001. One subscription covers the whole catalog, with usage measured in atoms, a per-call unit that reflects the compute work each request does. Every endpoint is MCP-ready by default through &lt;a href="https://apyhub.com/mcp" rel="noopener noreferrer"&gt;ApyHub MCP&lt;/a&gt;. ApyHub is headquartered in Amsterdam, with offices in the Netherlands, Greece and India, and serves 65,000+ monthly developer workspaces. The free plan needs no credit card, and API providers can publish to the catalog through the &lt;a href="https://apyhub.com/api-provider" rel="noopener noreferrer"&gt;provider program&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>We built an MCP server for 1500+ Tools for devs and their agents</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Fri, 04 Sep 2026 08:54:07 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/we-built-an-mcp-server-for-1500-tools-for-devs-and-their-agents-595</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/we-built-an-mcp-server-for-1500-tools-for-devs-and-their-agents-595</guid>
      <description>&lt;p&gt;Hey everyone, I’m one of the co-founders of ApyHub.&lt;/p&gt;

&lt;p&gt;We recently built an MCP server that lets your agent use 1500+ tools from one connector.&lt;/p&gt;

&lt;p&gt;The idea is pretty simple.&lt;/p&gt;

&lt;p&gt;Instead of wiring an MCP server to one API, then another, then another, the agent can search the catalog, find the API it needs, see what it does, and call it.&lt;/p&gt;

&lt;p&gt;ApyHub has 400+ services and 1,500+ endpoints across things like file conversion, OCR, data extraction, validation, geolocation, SEO, image processing, and AI.&lt;/p&gt;

&lt;p&gt;So you can give an agent a fairly small set of tools and still have access to a much larger set of capabilities.&lt;/p&gt;

&lt;p&gt;Some examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;convert a Word document to PDF&lt;/li&gt;
&lt;li&gt;extract text from a PDF&lt;/li&gt;
&lt;li&gt;OCR a scanned document&lt;/li&gt;
&lt;li&gt;extract text from a webpage&lt;/li&gt;
&lt;li&gt;compress a video&lt;/li&gt;
&lt;li&gt;generate a QR code&lt;/li&gt;
&lt;li&gt;validate an EU VAT number&lt;/li&gt;
&lt;li&gt;validate email DNS&lt;/li&gt;
&lt;li&gt;convert currencies&lt;/li&gt;
&lt;li&gt;parse a resume&lt;/li&gt;
&lt;li&gt;check SERP rankings&lt;/li&gt;
&lt;li&gt;generate speech from text&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The useful part is that these can also be chained.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;p&gt;Take a scanned invoice → OCR it → extract the data → validate the VAT number → convert the currency → generate a PDF.&lt;/p&gt;

&lt;p&gt;Or:&lt;/p&gt;

&lt;p&gt;Extract a webpage → check readability → summarize it → check its search ranking.&lt;/p&gt;

&lt;p&gt;You don't have to build those workflows ahead of time. The agent can compose the calls based on the task.&lt;/p&gt;

&lt;p&gt;There is also curation.&lt;/p&gt;

&lt;p&gt;Giving an agent 1,500 tools creates tool bloat and overwhelms the model with context.&lt;/p&gt;

&lt;p&gt;Instead, you can select which endpoints an agent may use: a document agent might have 15–20 endpoints, while an enrichment agent uses a different set.&lt;/p&gt;

&lt;p&gt;One other thing we cared about was credentials.&lt;/p&gt;

&lt;p&gt;Normally, connecting an agent to ten vendors requires ten API keys stored somewhere in the agent’s environment or context. Those traces eventually appear in logs, observability tools, bug reports, etc.&lt;/p&gt;

&lt;p&gt;With ApyHub, the agent uses one scoped key.&lt;/p&gt;

&lt;p&gt;The MCP server is at:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apyhub.com/mcp" rel="noopener noreferrer"&gt;https://apyhub.com/mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the catalog is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apyhub.com/catalog" rel="noopener noreferrer"&gt;https://apyhub.com/catalog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The thing I’m most interested in is what people actually try to make their agents do with the catalog. There are a lot of boring utilities in there already, but there are always going to be things we haven't covered yet.&lt;/p&gt;

&lt;p&gt;If you have any questions, I’m happy to assist :)&lt;/p&gt;

&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%2F46c68ejhnh8lhn77p9kw.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%2F46c68ejhnh8lhn77p9kw.png" alt=" " width="800" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>api</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How Should an API Client Look in 2026? A Comparison of the Field</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Tue, 23 Jun 2026 08:23:08 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/how-should-an-api-client-look-in-2026-a-comparison-of-the-field-3pl9</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/how-should-an-api-client-look-in-2026-a-comparison-of-the-field-3pl9</guid>
      <description>&lt;p&gt;API tooling is having a rough year, and that's actually the interesting part.&lt;/p&gt;

&lt;p&gt;Postman killed free teams in March. Insomnia forced cloud sync in v8 and is still rebuilding trust. Bruno is absorbing most of the migration wave. And a handful of smaller tools are betting the whole category has been building the wrong abstraction since day one.&lt;/p&gt;

&lt;p&gt;I spent the last few weeks actually using seven of these tools on the same real workflow: REST and GraphQL requests, env vars across staging and prod, auth, and docs that are supposed to stay in sync with the requests they describe. Here's where each one actually stands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real architectural split
&lt;/h2&gt;

&lt;p&gt;Most comparisons line up feature checkboxes: offline, yes/no; Git integration, yes/no. That misses the real fault line in the category, which isn't a feature. It's where your API definitions actually live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workspace-centric tools&lt;/strong&gt; (Postman, Insomnia, Hoppscotch) store collections inside the app's own data model. You can export them, sync to Git, even self-host the sync layer. But the source of truth is the app's internal state. The export is a snapshot, not the real thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File-centric tools&lt;/strong&gt; (Bruno, Voiden, Yaak, in different ways) store collections as actual files on disk, read directly by Git, your editor, and CI, with no export step. The file &lt;em&gt;is&lt;/em&gt; the source of truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open source matters here too, and not as a checkbox.&lt;/strong&gt; A closed-source tool can disappear, get acquired, or change its pricing model overnight, and your only option is to migrate. Every open-source tool below can be forked, audited, or kept alive by the community if the maintainers walk away. That's a real, structural advantage worth weighing on its own, separate from whatever feature set a tool ships with this month.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Open source&lt;/th&gt;
&lt;th&gt;Architecture&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Best known for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Voiden&lt;/td&gt;
&lt;td&gt;✅ Apache 2.0&lt;/td&gt;
&lt;td&gt;File-centric, git-native&lt;/td&gt;
&lt;td&gt;Markdown (&lt;code&gt;.void&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Composable blocks, readable without the app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postman&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Workspace-centric, cloud-first&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;Richest feature set, MCP integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insomnia&lt;/td&gt;
&lt;td&gt;✅ Apache&lt;/td&gt;
&lt;td&gt;Workspace-centric, cloud sync (v8+)&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;GraphQL heritage, Kong backing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hoppscotch&lt;/td&gt;
&lt;td&gt;✅ MIT&lt;/td&gt;
&lt;td&gt;Web-first&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;Largest FOSS star count, browser workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bruno&lt;/td&gt;
&lt;td&gt;✅ MIT&lt;/td&gt;
&lt;td&gt;File-centric, git-native&lt;/td&gt;
&lt;td&gt;Bru Lang (proprietary)&lt;/td&gt;
&lt;td&gt;Community momentum, fast release cadence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yaak&lt;/td&gt;
&lt;td&gt;✅ MIT&lt;/td&gt;
&lt;td&gt;File-centric, git-friendly&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;Minimal, intuitive design, Tauri/Rust stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl&lt;/td&gt;
&lt;td&gt;✅ MIT&lt;/td&gt;
&lt;td&gt;No GUI, no collections&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;The universal baseline, scriptable, everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Postman
&lt;/h2&gt;

&lt;p&gt;Still the default for teams that haven't migrated, and still genuinely capable: the richest feature set here, an established MCP integration for agent workflows, tooling most developers already know. Open source: no. The free-team removal in March 2026 was a real breaking point, and the cloud-first model means your definitions live on Postman's infrastructure, not yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Insomnia
&lt;/h2&gt;

&lt;p&gt;Real GraphQL-first heritage and Kong's backing. Open source under Apache, which is a genuine point in its favor. But forcing cloud sync in v8 cost it real community trust that hasn't fully come back, and Git sync is an add-on to a cloud-first design, not the actual architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hoppscotch
&lt;/h2&gt;

&lt;p&gt;The largest open-source star count in the category, MIT licensed, and a genuinely good browser-based workflow. Also the clearest example of a different category: web-first, not file-centric, not really part of the offline/git-native model the rest of this list is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bruno
&lt;/h2&gt;

&lt;p&gt;The closest direct competitor to the file-centric model. Real community momentum, a fast release cadence, MIT licensed, genuinely offline-first and git-native: &lt;code&gt;.bru&lt;/code&gt; files on disk, no export, no account. The real gap: &lt;code&gt;.bru&lt;/code&gt; is a proprietary format. A &lt;code&gt;.bru&lt;/code&gt; file means nothing without Bruno installed. No plugin SDK yet either, despite it being the single most-requested feature on its tracker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Yaak
&lt;/h2&gt;

&lt;p&gt;Built by the same person who built and sold Insomnia, and it shows: Yaak's whole pitch is design restraint, doing less, staying out of your way, instead of stacking on more features. MIT licensed, built on Tauri and Rust rather than Electron, which gives it a noticeably lighter footprint than most of this list. It mirrors workspaces to your filesystem for Git, so it's file-centric in practice, but its actual differentiator isn't the file format, it's the interface itself: minimal by default, with depth available only when you go looking for it. Worth watching, with one real caveat: community contributions are currently limited to bug fixes, not new features, so its roadmap is more centrally controlled than Bruno's or Voiden's.&lt;/p&gt;

&lt;h2&gt;
  
  
  curl
&lt;/h2&gt;

&lt;p&gt;Not really in the same category, and worth including anyway, because it's the baseline everyone else is implicitly compared against. No GUI, no collections, no environments as a concept. Just a request, scriptable, in every CI pipeline and every dev's muscle memory. Nothing here replaces curl for a one-off request; the entire point of the other six tools is managing the complexity curl doesn't try to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voiden
&lt;/h2&gt;

&lt;p&gt;Same file-centric premise as Bruno, with two real differences: the format is plain Markdown, not a proprietary DSL, and the request itself is built from composable blocks instead of a fixed form. A &lt;code&gt;.void&lt;/code&gt; file is readable in any editor, on GitHub, in a PR, with zero dependency on Voiden being installed. Headers, auth, and params are reusable blocks, not duplicated fields. Apache 2.0 licensed. The honest gap: a much smaller community right now than Bruno or Hoppscotch, since the category itself is still young.&lt;/p&gt;

&lt;p&gt;There's a short demo comparing Voiden and Postman side by side on the same request if you want to see the workflow difference instead of just reading about it. ⬇️&lt;/p&gt;

&lt;p&gt;&lt;a href="https://youtu.be/mpMarhwODY4" rel="noopener noreferrer"&gt;https://youtu.be/mpMarhwODY4&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try Voiden here: &lt;a href="https://voiden.md/download" rel="noopener noreferrer"&gt;https://voiden.md/download&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one should you actually use?
&lt;/h2&gt;

&lt;p&gt;No clean winner, because the field is genuinely split on what people are optimizing for.&lt;/p&gt;

&lt;p&gt;Need the deepest feature set and already have a working Postman flow? The migration cost might not be worth it yet.&lt;/p&gt;

&lt;p&gt;Want file-centric and git-native with the largest existing community today? Bruno, with the tradeoff that your files are tied to its own format.&lt;/p&gt;

&lt;p&gt;Want the same file-centric model with a lighter footprint and a deliberately minimal interface? Yaak, with the tradeoff that the roadmap is more centrally controlled right now.&lt;/p&gt;

&lt;p&gt;Want files that are readable without the tool installed, real composability instead of fixed forms, and you're fine being on a younger, smaller project? Voiden.&lt;/p&gt;

&lt;p&gt;And if you just need to fire off one request without installing anything? curl was already enough.&lt;/p&gt;

&lt;p&gt;The actual story of 2026 isn't that one tool won. It's that "where do your API definitions live, and who can keep the tool alive if the maintainers disappear" became real questions instead of implementation details nobody thought about.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Star counts, license status, and contribution policies above reflect late May 2026 and move fast; check each project's repo before deciding based on community size alone.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>graphql</category>
      <category>programming</category>
      <category>testing</category>
    </item>
    <item>
      <title>Modern API Tooling: An 11-Principle Scorecard</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Fri, 19 Jun 2026 23:59:48 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/modern-api-tooling-an-11-principle-scorecard-4lh8</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/modern-api-tooling-an-11-principle-scorecard-4lh8</guid>
      <description>&lt;p&gt;A while ago I came across a really good article on the future of API tooling: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://efp.asia/blog/2025/12/24/api-tooling-crisis/" rel="noopener noreferrer"&gt;https://efp.asia/blog/2025/12/24/api-tooling-crisis/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It lays out a set of criteria for what modern API clients should look like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local-first, filesystem-centric: collections and requests live directly in the project repo&lt;/li&gt;
&lt;li&gt;Support for OpenAPI specs and GraphQL schemas, plus straightforward testing&lt;/li&gt;
&lt;li&gt;Zero login wall: works fully offline without accounts or mandatory cloud sync&lt;/li&gt;
&lt;li&gt;Git-native collaboration: uses version control instead of proprietary cloud workspaces or seat licenses&lt;/li&gt;
&lt;li&gt;Native performance: built with high-performance tech (e.g. Rust), not browser wrappers&lt;/li&gt;
&lt;li&gt;Extensible design: modular plugin architecture that doesn’t bloat the core&lt;/li&gt;
&lt;li&gt;Universal imports: OpenAPI, GraphQL, Postman collections, etc.&lt;/li&gt;
&lt;li&gt;Proxy agnostic: works cleanly with interception tools like Charles or mitmproxy&lt;/li&gt;
&lt;li&gt;Scripting and auth flows: pre-request and post-response hooks&lt;/li&gt;
&lt;li&gt;Straightforward testing: built-in code-based testing of API responses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I agree with all of these. The only thing I’d add is pricing as another important dimension.&lt;/p&gt;

&lt;p&gt;Recently I was experimenting with a pricing MCP and decided to run a quick test: evaluate all the API clients I know against these criteria using &lt;a href="https://pulse.pricingsaas.com/" rel="noopener noreferrer"&gt;https://pulse.pricingsaas.com/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Scoring was simple:&lt;/p&gt;

&lt;p&gt;Each principle is rated 0 (missing), 1 (partial), or 2 (fully met). Final score is normalized to a 10-point scale.&lt;/p&gt;

&lt;p&gt;Overall rankings (out of 10)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bruno — 8/10&lt;/li&gt;
&lt;li&gt;Voiden — 8/10&lt;/li&gt;
&lt;li&gt;Yaak — 7/10&lt;/li&gt;
&lt;li&gt;Insomnia — 6/10&lt;/li&gt;
&lt;li&gt;cURL — 6/10&lt;/li&gt;
&lt;li&gt;HTTPie — 5/10&lt;/li&gt;
&lt;li&gt;Postman — 5/10&lt;/li&gt;
&lt;li&gt;Requestly — 5/10&lt;/li&gt;
&lt;li&gt;Hoppscotch — 4/10&lt;/li&gt;
&lt;li&gt;Apidog — 4/10&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Key takeaway: no perfect API client yet&lt;/p&gt;

&lt;p&gt;Every tool fails on at least a few of the 11 principles, so none fully matches the “ideal” API tooling model. The ceiling right now seems to be around 8/10.&lt;/p&gt;

&lt;p&gt;What separates the top tools (Bruno, Voiden, Yaak)&lt;/p&gt;

&lt;p&gt;The strongest tools tend to share a few traits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local-first, filesystem-based storage&lt;/li&gt;
&lt;li&gt;Git-native collaboration&lt;/li&gt;
&lt;li&gt;No forced login (or minimal account gating)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where they still fall short is usually native performance, since most GUI tools are built on Electron.&lt;/p&gt;

&lt;p&gt;A few structural patterns across the space&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Electron is the default weakness
Most GUI tools (Bruno, Insomnia, Postman, Requestly, Hoppscotch, Apidog) rely on Electron. The only real exception is Yaak, which uses Tauri + Rust and is much closer to true native performance.&lt;/li&gt;
&lt;li&gt;Zero-login is a clear dividing line
Several major tools are effectively excluded because they require accounts:
Postman, Insomnia, Hoppscotch, Apidog.&lt;/li&gt;
&lt;li&gt;Pricing is becoming a core constraint
Most tools are increasingly account-gated or paid-tier driven. Voiden stands out as fully free with no tiering, while Postman is heavily penalized due to its pricing model and limited free tier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tool-level notes (high level)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bruno: strong balance of scripting, testing, and Git-native design, but held back by Electron&lt;/li&gt;
&lt;li&gt;Voiden: most aligned overall, especially due to being fully free and extensible&lt;/li&gt;
&lt;li&gt;Yaak: best native performance, but lacks scripting and testing&lt;/li&gt;
&lt;li&gt;Insomnia: strong feature set, but weakened by login and pricing dependence&lt;/li&gt;
&lt;li&gt;Postman: best-in-class scripting and testing, but cloud-first and heavy&lt;/li&gt;
&lt;li&gt;cURL / HTTPie: great primitives, but not full API design environments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Final insight&lt;/p&gt;

&lt;p&gt;The ideal API client would likely combine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Yaak’s native Rust performance&lt;/li&gt;
&lt;li&gt;Voiden or Bruno-level scripting, testing, and Git-native workflows&lt;/li&gt;
&lt;li&gt;Right now, the space is split between:&lt;/li&gt;
&lt;li&gt;Local-first developer tools (Bruno, Voiden, Yaak)&lt;/li&gt;
&lt;li&gt;Cloud-first enterprise platforms (Postman, Apidog, Hoppscotch, Insomnia)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full results here: &lt;a href="https://share.pricingsaas.com/reports/ps_-1DO_vX/20260619_220350_52a40f15/api-client-scorecard-v5.html" rel="noopener noreferrer"&gt;https://share.pricingsaas.com/reports/ps_-1DO_vX/20260619_220350_52a40f15/api-client-scorecard-v5.html&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>tooling</category>
      <category>testing</category>
      <category>rest</category>
    </item>
    <item>
      <title>Voiden : offline API client, Git-native, Markdown-based. Looking for contributors in TypeScript/Electron/plugin development.</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Mon, 25 May 2026 11:17:47 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/voiden-offline-api-client-git-native-markdown-based-looking-for-contributors-in-2f09</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/voiden-offline-api-client-git-native-markdown-based-looking-for-contributors-in-2f09</guid>
      <description>&lt;p&gt;Hey there,&lt;/p&gt;

&lt;p&gt;Bit of context first: I am a member of a 30 people+ team and most API clients we used felt like they were built for a different job than what we actually did.&lt;/p&gt;

&lt;p&gt;Our team lives in Git, our communication is happening on slack, our docs written and maintained on confluence and after some time they always drift away from the actual requests inside Postman.&lt;/p&gt;

&lt;p&gt;So we built and open sourced Voiden a few months ago: an API tool where all that: specs, tests, context and docs are always together in the same executable plain text file (markdown). We also made this Git native so that every change is versioned and tracked just like code.&lt;/p&gt;

&lt;p&gt;The last change we have made is to add a Runner so that one can run the files directly from the terminal and CI/CD pipelines.&lt;/p&gt;

&lt;p&gt;here is the tool: &lt;a href="https://voiden.md/download" rel="noopener noreferrer"&gt;https://voiden.md/download&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;repo: &lt;a href="https://github.com/VoidenHQ/voiden" rel="noopener noreferrer"&gt;https://github.com/VoidenHQ/voiden&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Looking for contributors in TypeScript/Electron/plugin development. &lt;/p&gt;

&lt;p&gt;Good first issues: &lt;br&gt;
&lt;a href="https://github.com/VoidenHQ/voiden/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22" rel="noopener noreferrer"&gt;https://github.com/VoidenHQ/voiden/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;and a discussion:&lt;br&gt;
&lt;a href="https://github.com/VoidenHQ/voiden/discussions/386" rel="noopener noreferrer"&gt;https://github.com/VoidenHQ/voiden/discussions/386&lt;/a&gt;&lt;/p&gt;

&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.amazonaws.com%2Fuploads%2Farticles%2Fd407pftc0yca9x5r6z72.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.amazonaws.com%2Fuploads%2Farticles%2Fd407pftc0yca9x5r6z72.png" alt=" " width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>contributorswanted</category>
      <category>api</category>
      <category>electron</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Cross-Platform Desktop Wars: Electron vs Tauri: How do you explain the tradeoffs to users?</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Wed, 25 Mar 2026 14:59:25 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/cross-platform-desktop-wars-electron-vs-tauri-how-do-you-explain-the-tradeoffs-to-users-2948</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/cross-platform-desktop-wars-electron-vs-tauri-how-do-you-explain-the-tradeoffs-to-users-2948</guid>
      <description>&lt;p&gt;Hello,&lt;/p&gt;

&lt;p&gt;I am writing cause I wanted to get some opinions from folks here that have actually built and shipped with Electron (or Tauri).&lt;/p&gt;

&lt;p&gt;Background: Building an API IDE on Electron. Not really “just an API client”, and not a(nother) thin wrapper around a webapp either. It’s a pretty original desktop tool with a lot of editor/IDE-like behavior - not the typical form centric behavior that postman or others have: local workflows, richer interactions, and some things that honestly would have been much harder for us to build and iterate on this quickly in a more constrained setup. Thats why Electron.&lt;/p&gt;

&lt;p&gt;this is the tool: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/VoidenHQ/voiden" rel="noopener noreferrer"&gt;https://github.com/VoidenHQ/voiden&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now, as adoption is growing, we are starting to get the usual questions about memory footprint and app size.&lt;/p&gt;

&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.amazonaws.com%2Fuploads%2Farticles%2Fwn3k9wmn5jk47qrfe1wy.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fwn3k9wmn5jk47qrfe1wy.webp" alt=" " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The (slightly) frustrating part is this:&lt;/p&gt;

&lt;p&gt;When the app is actually being used, the app-side memory is often pretty reasonable. In many normal cases we are seeing something like 50–60 MB for the actual usage we care about (even added it in the app itself for people to check it out).&lt;/p&gt;

&lt;p&gt;But then people open Activity Monitor, see all the Chromium/Electron-related processes, and the conversation immediately becomes:&lt;/p&gt;

&lt;p&gt;“yeah but Tauri would use way less”&lt;/p&gt;

&lt;p&gt;And then, without realizing, I suddenly end up talking and philosophizing about Electron, instead of discussing the tool itself (which is what I am passionate about :)&lt;/p&gt;

&lt;p&gt;And of course, I get it. The broader footprint is real. Chromium is not free. Electron has overhead. Pretending otherwise would be foolish. So we are constantly optimizing what we can, and we will keep doing so…&lt;/p&gt;

&lt;p&gt;At the same time, I do feel that a lot of these comparisons feel weirdly flattened. For example people often compare:&lt;/p&gt;

&lt;p&gt;full Electron process footprint VS the smallest possible Tauri/native mental model&lt;/p&gt;

&lt;p&gt;…without always accounting for development speed, cross-platform consistency, ecosystem maturity, plugin/runtime complexity, UI flexibility, and the fact that some apps are doing much more than others. Which is by the way the reason that we went with Electron.&lt;/p&gt;

&lt;p&gt;So all this context to get to my real question, which is:&lt;/p&gt;

&lt;p&gt;How do you explain this tradeoff to users in a way that feels honest and understandable, without sounding like you are making excuses for Electron?&lt;br&gt;
And also, for those of you who have had this conversation a hundred times already:&lt;/p&gt;

&lt;p&gt;What do you say when people reduce the whole discussion to “Electron bad, Tauri good”?&lt;/p&gt;

&lt;p&gt;Have you found a good way to explain footprint in practical terms?&lt;/p&gt;

&lt;p&gt;Where do you think optimization actually matters, vs where people are mostly reacting to the idea of Electron?&lt;/p&gt;

&lt;p&gt;Mostly trying to learn how others think about this , especially those who have built more serious desktop products and had to answer these questions in the wild.&lt;/p&gt;

&lt;p&gt;Would love your thoughts and advice!&lt;/p&gt;

</description>
      <category>electron</category>
      <category>tauri</category>
    </item>
    <item>
      <title>Burgers, APIs, and the State of Developer Marketing</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Fri, 13 Mar 2026 13:43:13 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/burgers-apis-and-the-state-of-developer-marketing-2kkk</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/burgers-apis-and-the-state-of-developer-marketing-2kkk</guid>
      <description>&lt;p&gt;Wanted to share some thoughts as a founder who has been building and shipping developer tools and applications (including open source, Voiden).&lt;/p&gt;

&lt;p&gt;When I started, every discussion and engagement I had with dev-tool marketers and experts revolved around the idea that effective developer marketing should be a combination of genuinely helpful content, engaging tutorials, thoughtful blog posts, and insightful guides that actually help people get unblocked or see common problems from a new perspective.&lt;/p&gt;

&lt;p&gt;I wanted to — and still want to — contribute to that kind of ecosystem.&lt;/p&gt;

&lt;p&gt;And to be clear, I still believe this is what good developer marketing looks like.&lt;/p&gt;

&lt;p&gt;But I also realized that a lot of what passes for “dev marketing” today (especially in the API tooling space that I’m in) is much more about SEO, keyword stuffing, backlinks, and thinly veiled sponsored content.&lt;/p&gt;

&lt;p&gt;Case in point: &lt;/p&gt;

&lt;p&gt;I came across a post on dev.to titled:&lt;/p&gt;

&lt;p&gt;“12 open-source alternatives to popular dev tools.” &lt;/p&gt;

&lt;p&gt;Great idea, right?&lt;/p&gt;

&lt;p&gt;Except the first tool listed wasn’t open source at all — it’s a paid SaaS.&lt;/p&gt;

&lt;p&gt;Some of the “alternatives” weren’t even truly comparable. It felt much more like keyword stuffing and backlinking than a genuine resource meant to help developers.&lt;/p&gt;

&lt;p&gt;And some of the comments below were things like:&lt;/p&gt;

&lt;p&gt;“Great list!”&lt;/p&gt;

&lt;p&gt;So I made a joke in the comments calling out the issue. Something along the lines of:&lt;/p&gt;

&lt;p&gt;“You could also add the tool to a list of ‘Top burger joints in Manhattan’, since people could also test APIs while eating a burger, right?” or something like that.&lt;/p&gt;

&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.amazonaws.com%2Fuploads%2Farticles%2Fd7ayxt3y7asizyj8dcxr.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.amazonaws.com%2Fuploads%2Farticles%2Fd7ayxt3y7asizyj8dcxr.png" alt=" " width="800" height="524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In hindsight, maybe not the best joke ever made.&lt;/p&gt;

&lt;p&gt;The author of the post apparently thought the same and deleted my comment.&lt;/p&gt;

&lt;p&gt;Twice.&lt;/p&gt;

&lt;p&gt;But this isn’t really about that one tool or that one post.&lt;/p&gt;

&lt;p&gt;I have seen many companies do similar things.&lt;/p&gt;

&lt;p&gt;That said, I also don’t want to overgeneralize and in a weird way I can even sympathize a bit.&lt;/p&gt;

&lt;p&gt;A lot of founders and makers fall into a strange trap: the idea that self-promotion is inherently bad.&lt;/p&gt;

&lt;p&gt;So instead of honestly sharing what they’ve built or what they’ve learned, they look for clever tactics or “growth hacks” to appear neutral, helpful, or educational while still getting attention.&lt;/p&gt;

&lt;p&gt;In a way, they’re staging the play they think developers want to see.&lt;/p&gt;

&lt;p&gt;It’s understandable (maybe).&lt;/p&gt;

&lt;p&gt;But it can easily cross the line into something misleading or manipulative.&lt;/p&gt;

&lt;p&gt;I honestly don’t know whether this happens because people underestimate developers’ ability to see through it, or because the SEO benefits eventually pay off anyway.&lt;/p&gt;

&lt;p&gt;Maybe both.&lt;/p&gt;

&lt;p&gt;From my experience, developers are skeptical and notoriously allergic to being marketed to. And when marketing stretches the truth this far, it can slowly erode trust — not just in one tool, but in the ecosystem as a whole.&lt;/p&gt;

&lt;p&gt;The lesson I’m taking away as a founder:&lt;/p&gt;

&lt;p&gt;Authentic engagement wins.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build tools that solve real problems.&lt;/li&gt;
&lt;li&gt;Share honest insights.&lt;/li&gt;
&lt;li&gt;Be simple and transparent about what you are doing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A little vulnerability goes a long way.&lt;/p&gt;

&lt;p&gt;Anyway, enough whining.&lt;/p&gt;

&lt;p&gt;Let’s make it fun:&lt;/p&gt;

&lt;p&gt;What’s the most absurd developer marketing you’ve seen lately that made you roll your eyes?&lt;/p&gt;

</description>
      <category>api</category>
      <category>open</category>
      <category>opensource</category>
      <category>seo</category>
    </item>
    <item>
      <title>Voiden is now extensible via community plugins</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Tue, 10 Mar 2026 21:07:09 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/voiden-is-now-extensible-via-community-plugins-24e6</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/voiden-is-now-extensible-via-community-plugins-24e6</guid>
      <description>&lt;p&gt;A lot of developer tools follow the same pattern: They start simple and then new ideas come. Then these these new ideas become built-in features.&lt;/p&gt;

&lt;p&gt;This is of course fine (and natural) but over time, the core can become huge, harder to maintain and evolve, and slower to experiment with.&lt;/p&gt;

&lt;p&gt;When we started building Voiden we wanted to avoid this trap.&lt;/p&gt;

&lt;p&gt;What we observed from our experience and also while talking to other developers is that different teams use API tools very differently, in different scenarios. &lt;/p&gt;

&lt;p&gt;So we decided that forcing everyone into the same giant feature set doesn’t really make sense.&lt;/p&gt;

&lt;p&gt;So, instead of putting everything in the core, we decided to have the tool grow through a plugin system. This way, it can be flexible and extensible since new functionality can live outside of the core tool.&lt;/p&gt;

&lt;p&gt;The idea is simple: &lt;/p&gt;

&lt;p&gt;Keep the core lean. Let the ecosystem grow around it. Developers can build extensions independently and users can just install the pieces they actually need. &lt;/p&gt;

&lt;p&gt;Keeping Voiden flexible and extensible is one of the key design principles we had from day one and I am very happy with this milestone. &lt;/p&gt;

&lt;p&gt;Curious what kinds of extensions people will build 🙂 (we already have a few community plugins in the works)...&lt;/p&gt;

&lt;p&gt;Start contributing: &lt;a href="https://docs.voiden.md/docs/plugins/build-a-plugin" rel="noopener noreferrer"&gt;https://docs.voiden.md/docs/plugins/build-a-plugin&lt;/a&gt;&lt;br&gt;
Repo here: &lt;a href="https://github.com/VoidenHQ/voiden" rel="noopener noreferrer"&gt;https://github.com/VoidenHQ/voiden&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>voiden</category>
      <category>testing</category>
    </item>
    <item>
      <title>API security and compliance in 2026</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Fri, 06 Mar 2026 10:22:05 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/api-security-and-compliance-in-2026-35l7</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/api-security-and-compliance-in-2026-35l7</guid>
      <description>&lt;p&gt;Choosing an API based on functionality is one thing. Getting it approved for production is where things can get tricky.&lt;/p&gt;

&lt;p&gt;The moment your product sends data to a third-party service, questions come up:&lt;/p&gt;

&lt;p&gt;→ Where is the data stored? &lt;br&gt;
→ How long is it retained?&lt;br&gt;
→ Are third-party processors involved?&lt;br&gt;
→ Is it compliant with GDPR, SOC 2, or other standards?&lt;/p&gt;

&lt;p&gt;This is often the step that slows down adoption more than the integration itself.&lt;/p&gt;

&lt;p&gt;At ApyHub, we tackle this head-on. &lt;/p&gt;

&lt;p&gt;Every API in our catalog comes with clear, standardized info on data handling and compliance. That means teams can:&lt;/p&gt;

&lt;p&gt;→ See certifications and disclosures upfront (GDPR, SOC 2, ISO 27001)&lt;br&gt;
→ Understand exactly where and how data is stored and handled &lt;br&gt;
→Track retention and third-party processing&lt;br&gt;
→ Stay up-to-date as APIs evolve&lt;/p&gt;

&lt;p&gt;The result? Teams can find, evaluate and approve APIs faster, with confidence, without digging through scattered docs or privacy policies.&lt;/p&gt;

&lt;p&gt;We are happy as our catalog keeps growing with new APIs. But in the end, it’s not just about functionality.&lt;/p&gt;

&lt;p&gt;It’s about trust, compliance, and true data sovereignty.&lt;/p&gt;

&lt;p&gt;Explore certified APIs here: &lt;a href="https://apyhub.com/catalog" rel="noopener noreferrer"&gt;https://apyhub.com/catalog&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
    </item>
    <item>
      <title>Scripting in API tools?</title>
      <dc:creator>Nikolas Dimitroulakis</dc:creator>
      <pubDate>Tue, 24 Feb 2026 17:43:38 +0000</pubDate>
      <link>https://dev.to/nikolas_dimitroulakis_d23/pre-post-request-scripts-javascript-and-python-2icd</link>
      <guid>https://dev.to/nikolas_dimitroulakis_d23/pre-post-request-scripts-javascript-and-python-2icd</guid>
      <description>&lt;p&gt;Spent months thinking about scripting in API tools.&lt;/p&gt;

&lt;p&gt;Honestly, most tools don’t go far enough. They stop at JavaScript, a limited sandbox and a system that is not really extensible. &lt;/p&gt;

&lt;p&gt;That works for small tests, but it can also quickly break down when you want to start building real, multi-step workflows.&lt;/p&gt;

&lt;p&gt;We wanted to go further. So we spent time observing how developers actually work with APIs, talking to them, and learning from the patterns and frustrations in real workflows. We saw token rotations handled manually, small helper scripts scattered across repos, and tools that forced engineers into JavaScript even when their stack was Python, Go etc. &lt;/p&gt;

&lt;p&gt;So for Voiden Pre &amp;amp; Post Request Scripts adapt to how developers work. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JavaScript + Python (first-class): use the language you already know and trust&lt;/li&gt;
&lt;li&gt;Real runtimes &amp;amp; package imports: run actual code, import libraries, 
&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.amazonaws.com%2Fuploads%2Farticles%2Fjxvnqrintbg1p2vmh61u.gif" alt=" " width="200" height="139"&gt;reuse existing logic&lt;/li&gt;
&lt;li&gt;Stateful workflows: share variables, store data, and chain requests dynamically&lt;/li&gt;
&lt;li&gt;Orchestration-ready: automate multi-step flows, token rotations, and dependent API calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Again, it's a basic principle we had from day 1: API tools should not dictate how devs think. It should adapt to how they work.&lt;/p&gt;

&lt;p&gt;We believe this is one of the most powerful scripting systems available in any API client today. &lt;/p&gt;

&lt;p&gt;And we are just getting started.&lt;/p&gt;

&lt;p&gt;🌐 Beta: &lt;a href="https://voiden.md/download#beta" rel="noopener noreferrer"&gt;https://voiden.md/download#beta&lt;/a&gt;&lt;br&gt;
📂 GitHub: &lt;a href="https://github.com/VoidenHQ/voiden" rel="noopener noreferrer"&gt;https://github.com/VoidenHQ/voiden&lt;/a&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>api</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
